-
+
+ 🌐
网络仪表盘
diff --git a/electron/src/renderer/src/RemoteBinding.tsx b/electron/src/renderer/src/RemoteBinding.tsx
deleted file mode 100644
index 9afa33c..0000000
--- a/electron/src/renderer/src/RemoteBinding.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import QRCode from 'qrcode'
-import { initApiFromMain } from './api'
-
-type RemoteStatus = {
- bindHost: string
- remoteEnabled: boolean
- port: number
- hasToken: boolean
- candidates: { address: string; name: string }[]
- envHostOverride: boolean
-}
-
-type RemoteBindingApi = {
- getStatus: () => Promise
- setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
- generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
- getBindPayload: (selectedIp: string) => Promise | null>
-}
-
-/** 远程控制:候选 IP、绑定 JSON、二维码(供手机扫码);手机端仍可改地址后保存 */
-export default function RemoteBinding() {
- const rb = (typeof window !== 'undefined'
- ? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
- : undefined) as RemoteBindingApi | undefined
-
- const [status, setStatus] = useState(null)
- const [busy, setBusy] = useState(false)
- const [msg, setMsg] = useState('')
- const [selectedIp, setSelectedIp] = useState('')
- const [payloadJson, setPayloadJson] = useState('')
- const [qrDataUrl, setQrDataUrl] = useState('')
- const [payloadVersion, setPayloadVersion] = useState(0)
-
- const load = useCallback(async () => {
- if (!rb?.getStatus) {
- setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。')
- return
- }
- try {
- const s = await rb.getStatus()
- setStatus(s)
- setMsg('')
- } catch (e) {
- setStatus(null)
- setMsg(e instanceof Error ? e.message : String(e))
- }
- }, [rb])
-
- useEffect(() => {
- void load()
- }, [load])
-
- useEffect(() => {
- if (!rb?.getStatus) return
- const id = window.setInterval(() => void load(), 5000)
- return () => clearInterval(id)
- }, [rb, load])
-
- const autoEnableRemoteOnce = useRef(false)
-
- useEffect(() => {
- if (!rb?.setRemoteEnabled || !status) return
- if (status.envHostOverride) return
- if (status.remoteEnabled) return
- if (autoEnableRemoteOnce.current) return
- autoEnableRemoteOnce.current = true
- let cancelled = false
- void (async () => {
- setBusy(true)
- try {
- const r = await rb.setRemoteEnabled(true)
- if (!r.ok || cancelled) return
- await initApiFromMain()
- await load()
- } finally {
- if (!cancelled) setBusy(false)
- }
- })()
- return () => {
- cancelled = true
- }
- }, [rb, status?.remoteEnabled, status?.envHostOverride, load])
-
- useEffect(() => {
- const list = status?.candidates ?? []
- if (!list.length) {
- setSelectedIp('')
- return
- }
- setSelectedIp((prev) => {
- if (prev && list.some((c) => c.address === prev)) return prev
- return list[0].address
- })
- }, [status?.candidates])
-
- useEffect(() => {
- if (!rb?.getBindPayload || !selectedIp.trim()) {
- setPayloadJson('')
- setQrDataUrl('')
- return
- }
- let cancelled = false
- void (async () => {
- setBusy(true)
- try {
- const p = await rb.getBindPayload(selectedIp.trim())
- if (cancelled || !p) {
- setPayloadJson('')
- setQrDataUrl('')
- return
- }
- const json = JSON.stringify(p)
- setPayloadJson(json)
- const url = await QRCode.toDataURL(json, { width: 220, margin: 1, errorCorrectionLevel: 'M' })
- if (!cancelled) setQrDataUrl(url)
- } catch (e) {
- if (!cancelled) {
- setPayloadJson('')
- setQrDataUrl('')
- setMsg(e instanceof Error ? e.message : String(e))
- }
- } finally {
- if (!cancelled) setBusy(false)
- }
- })()
- return () => {
- cancelled = true
- }
- }, [rb, selectedIp, status?.port, status?.hasToken, payloadVersion])
-
- const onGenerateToken = async () => {
- if (!rb?.generateToken) return
- setBusy(true)
- setMsg('')
- try {
- const r = await rb.generateToken()
- if (!r.ok) {
- setMsg(r.error)
- return
- }
- await initApiFromMain()
- await load()
- setPayloadVersion((v) => v + 1)
- } finally {
- setBusy(false)
- }
- }
-
- const onCopyPayload = async () => {
- if (!payloadJson) return
- try {
- await navigator.clipboard.writeText(payloadJson)
- setMsg('绑定 JSON 已复制')
- } catch {
- setMsg('复制失败,请手动选择下方 JSON')
- }
- }
-
- if (!rb?.getStatus) {
- return (
-
- )
- }
-
- const candidates = status?.candidates ?? []
-
- return (
-
- {status?.envHostOverride && (
-
- 当前由环境变量 WEB2_HOST 覆盖绑定地址,界面开关已禁用。
-
- )}
-
-
-
- API Token:{status?.hasToken ? '已配置' : '未配置'}
-
-
-
-
-
-
-
- 端口:{status?.port ?? '—'}
-
-
- {payloadJson && qrDataUrl && (
-
-
用手机 App 扫描下方二维码导入绑定;导入后可在 App 内修改 IP/域名与端口再保存。
-

-
- {payloadJson}
-
-
-
内容与二维码一致(v1 JSON,含 baseUrl / port / token)。
-
- )}
-
- {msg && (
-
- {msg}
-
- )}
-
- )
-}
diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx
index da0312d..1e9c240 100644
--- a/electron/src/renderer/src/SystemMetrics.tsx
+++ b/electron/src/renderer/src/SystemMetrics.tsx
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react'
import { apiFetch, apiUrl } from './api'
import { saveWorkspaceSnapshot } from './workspace'
import AppHostControls from './AppHostControls'
+import MetricBar, { toneFromPercent } from './components/ui/MetricBar'
+import NetSpeedBars from './components/ui/NetSpeedBars'
type Metrics = {
ts?: number
@@ -79,7 +81,8 @@ export default function SystemMetrics() {
-
+
+ 🖥️
本机负载与网速
@@ -87,7 +90,7 @@ export default function SystemMetrics() {
ffmpeg 进程。
-
已运行 {uptimeLabel}
+
⏱ 已运行 {uptimeLabel}
{err && (
@@ -97,31 +100,34 @@ export default function SystemMetrics() {
{warnings.length > 0 && (
{warnings.map((w) => (
- {w}
+ ⚠️ {w}
))}
)}
-
+
CPU
{data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
+
逻辑处理器 {data?.cpu_count_logical ?? '—'} 个
-
+
内存
{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}
+
{fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
-
+
全机网速
↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}
↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}
+
含所有网卡收发合计
@@ -138,6 +144,9 @@ export default function SystemMetrics() {
{data.disks.slice(0, 8).map((d) => (
{d.mountpoint}
+
+
+
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
diff --git a/electron/src/renderer/src/WeChatChatbot.tsx b/electron/src/renderer/src/WeChatChatbot.tsx
deleted file mode 100644
index b992f29..0000000
--- a/electron/src/renderer/src/WeChatChatbot.tsx
+++ /dev/null
@@ -1,293 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { apiFetch, apiUrl } from './api'
-
-type WechatConfigView = {
- configured: boolean
- proxyUrl: string
- apiKeyMasked: string
- deviceType: string
- proxy: string
- webhookHost: string
- webhookPort: number
- webhookPath: string
- accountId: string
- session: { wcId?: string; nickName?: string }
-}
-
-export default function WeChatChatbot() {
- const [cfg, setCfg] = useState(null)
- const [msg, setMsg] = useState('')
- const [busy, setBusy] = useState(false)
-
- const [apiKeyInput, setApiKeyInput] = useState('')
- const [proxyUrlInput, setProxyUrlInput] = useState('')
- const [deviceType, setDeviceType] = useState('ipad')
- const [proxyLine, setProxyLine] = useState('10')
-
- const [wId, setWId] = useState('')
- const [qrUrl, setQrUrl] = useState('')
- const [poll, setPoll] = useState(false)
- const [proxyStatus, setProxyStatus] = useState | null>(null)
-
- const load = useCallback(async () => {
- setMsg('')
- try {
- const r = await apiFetch(apiUrl('/chatbot/wechat/config'))
- if (!r.ok) throw new Error(String(r.status))
- const j = (await r.json()) as WechatConfigView
- setCfg(j)
- setProxyUrlInput(j.proxyUrl || '')
- setDeviceType(j.deviceType || 'ipad')
- setProxyLine(j.proxy || '10')
- setApiKeyInput('')
- } catch (e) {
- setCfg(null)
- setMsg(e instanceof Error ? e.message : String(e))
- }
- }, [])
-
- useEffect(() => {
- void load()
- }, [load])
-
- const saveConfig = async () => {
- setBusy(true)
- setMsg('')
- try {
- const body: Record = {
- proxyUrl: proxyUrlInput.trim(),
- deviceType,
- proxy: proxyLine.trim(),
- }
- if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
- const r = await apiFetch(apiUrl('/chatbot/wechat/config'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
- if (!r.ok) {
- const t = await r.text()
- throw new Error(t || String(r.status))
- }
- await load()
- } catch (e) {
- setMsg(e instanceof Error ? e.message : String(e))
- } finally {
- setBusy(false)
- }
- }
-
- const loadProxyStatus = async () => {
- setBusy(true)
- setMsg('')
- try {
- const r = await apiFetch(apiUrl('/chatbot/wechat/proxy/status'))
- const j = await r.json()
- if (!r.ok) {
- setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
- setProxyStatus(null)
- return
- }
- setProxyStatus(j as Record)
- } catch (e) {
- setMsg(e instanceof Error ? e.message : String(e))
- } finally {
- setBusy(false)
- }
- }
-
- const startQr = async () => {
- setBusy(true)
- setMsg('')
- setQrUrl('')
- setWId('')
- try {
- const r = await apiFetch(apiUrl('/chatbot/wechat/qr/start'), { method: 'POST' })
- const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
- if (!r.ok) {
- setMsg(j.error || String(r.status))
- return
- }
- if (!j.wId || !j.qrCodeUrl) {
- setMsg('未返回二维码')
- return
- }
- setWId(j.wId)
- setQrUrl(j.qrCodeUrl)
- setPoll(true)
- } catch (e) {
- setMsg(e instanceof Error ? e.message : String(e))
- } finally {
- setBusy(false)
- }
- }
-
- useEffect(() => {
- if (!poll || !wId) return
- const tick = async () => {
- try {
- const r = await apiFetch(apiUrl('/chatbot/wechat/login/check'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ wId }),
- })
- const j = (await r.json()) as {
- status?: string
- wcId?: string
- nickName?: string
- verifyUrl?: string
- error?: string
- }
- if (!r.ok) {
- setMsg(j.error || String(r.status))
- setPoll(false)
- return
- }
- if (j.status === 'logged_in') {
- setPoll(false)
- setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`)
- void load()
- return
- }
- if (j.status === 'need_verify' && j.verifyUrl) {
- setMsg(`需要辅助验证:${j.verifyUrl}`)
- }
- } catch (e) {
- setMsg(e instanceof Error ? e.message : String(e))
- setPoll(false)
- }
- }
- const id = window.setInterval(() => void tick(), 5000)
- void tick()
- return () => window.clearInterval(id)
- }, [poll, wId, load])
-
- return (
-
- )
-}
diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx
deleted file mode 100644
index 71ac055..0000000
--- a/electron/src/renderer/src/WorkspaceDashboard.tsx
+++ /dev/null
@@ -1,442 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { apiFetch, apiUrl } from './api'
-import { STORAGE_MULTI_CHANNEL_PRO } from './constants'
-import type { WorkspaceDbSnapshot } from './workspace'
-import { getWorkspace, saveWorkspaceSnapshot } from './workspace'
-import WorkspaceDeskBackdrop from './WorkspaceDeskBackdrop'
-import WorkspaceEventSparkline from './WorkspaceEventSparkline'
-
-function fmtTs(ts: unknown): string {
- if (typeof ts !== 'number') return '—'
- try {
- return new Date(ts).toLocaleString()
- } catch {
- return '—'
- }
-}
-
-type ClientOverview = {
- youtubeChannelCount?: number
- youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
- relayPairingPolicy?: string
- relayValidationErrors?: string[]
- relayNotes?: string[]
- wechat?: {
- configured?: boolean
- nickName?: string
- wcId?: string
- online?: boolean
- proxyError?: string | null
- }
-}
-
-type RelayLivePayload = {
- youtubeProcess?: string
- processStatus?: string
- processOnline?: boolean
- relayRows?: Array<{
- channelId?: string | null
- channelName?: string
- anchor?: string
- douyinHint?: string
- streamTitle?: string
- youtubeKeySuffix?: string
- durationSec?: number | null
- processOnline?: boolean
- }>
-}
-
-function fmtDur(sec: number | null | undefined): string {
- if (sec == null || Number.isNaN(sec)) return '—'
- const s = Math.floor(sec)
- const h = Math.floor(s / 3600)
- const m = Math.floor((s % 3600) / 60)
- const r = s % 60
- if (h > 0) return `${h}h${m}m${r}s`
- if (m > 0) return `${m}m${r}s`
- return `${r}s`
-}
-
-function SnapshotSummary({ data }: { data: unknown }) {
- if (!data || typeof data !== 'object') return —
- const d = data as Record
- if ('google' in d && 'douyin' in d) {
- const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
- const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
- return (
-
-
- Google
- {g?.reachable ? '可达' : '不可达'}
-
- {g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
- {g?.country ?? '—'}
-
-
-
- 源站
- {y?.reachable ? '可达' : '不可达'}
-
- {y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
- {y?.country ?? '—'}
-
-
-
- )
- }
- if ('cpu_percent' in d || 'memory' in d) {
- const m = d.memory as { percent?: number } | undefined
- return (
-
-
- CPU
-
- {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
-
-
-
- 内存
- {m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}
-
-
- )
- }
- return —
-}
-
-export default function WorkspaceDashboard() {
- const ws = getWorkspace()
- const [data, setData] = useState(null)
- const [overview, setOverview] = useState(null)
- const [err, setErr] = useState('')
- const [proMode, setProMode] = useState(false)
- const [proLive, setProLive] = useState(null)
-
- const load = useCallback(async () => {
- if (!ws?.getDashboard) {
- setErr('当前环境不可用')
- return
- }
- setErr('')
- try {
- const d = (await ws.getDashboard()) as WorkspaceDbSnapshot
- setData(d)
- let ov: ClientOverview | null = null
- try {
- const r = await apiFetch(apiUrl('/client/overview'))
- if (r.ok) {
- ov = (await r.json()) as ClientOverview
- setOverview(ov)
- }
- } catch {
- setOverview(null)
- }
- if (ov) {
- saveWorkspaceSnapshot('client_overview', ov)
- }
- } catch (e) {
- setErr(e instanceof Error ? e.message : String(e))
- }
- }, [ws])
-
- useEffect(() => {
- void load()
- const id = window.setInterval(() => void load(), 12000)
- return () => window.clearInterval(id)
- }, [load])
-
- useEffect(() => {
- try {
- setProMode(localStorage.getItem(STORAGE_MULTI_CHANNEL_PRO) === '1')
- } catch {
- setProMode(false)
- }
- const id = window.setInterval(() => {
- try {
- setProMode(localStorage.getItem(STORAGE_MULTI_CHANNEL_PRO) === '1')
- } catch {
- setProMode(false)
- }
- }, 3000)
- return () => window.clearInterval(id)
- }, [])
-
- const loadProLive = useCallback(async () => {
- if (!proMode) return
- try {
- const r = await apiFetch(apiUrl('/relay_pro/live_status'))
- if (r.ok) {
- setProLive((await r.json()) as RelayLivePayload)
- }
- } catch {
- setProLive(null)
- }
- }, [proMode])
-
- useEffect(() => {
- if (!proMode) return
- void loadProLive()
- const id = window.setInterval(() => void loadProLive(), 8000)
- return () => window.clearInterval(id)
- }, [proMode, loadProLive])
-
- const net = data?.snapshots?.network_status
- const sys = data?.snapshots?.system_metrics
-
- return (
-
-
-
-
-
-
-
- 仪表盘
-
-
-
-
-
- {err && (
-
- {err}
-
- )}
-
-
-
-
YouTube 频道配置
-
- -
- 频道数
- {overview?.youtubeChannelCount ?? 0}
-
- -
- 配对策略
- {overview?.relayPairingPolicy || '一源站对应一 YouTube key'}
-
-
- {overview?.youtubeChannels?.length ? (
-
- {overview.youtubeChannels.slice(0, 6).map((c) => (
-
- {c.name} · key {c.keyPreview || '未配置'} · {c.douyinPreview || '未配置源站'}
-
- ))}
-
- ) : (
-
暂无频道配置
- )}
-
-
-
配置体检
- {overview?.relayValidationErrors?.length ? (
-
- {overview.relayValidationErrors.slice(0, 5).map((x) => (
-
{x}
- ))}
-
- ) : (
-
未发现跨频道 key / 源站地址冲突
- )}
- {overview?.relayNotes?.length ? (
-
- {overview.relayNotes.slice(0, 4).map((x) => (
-
{x}
- ))}
-
- ) : null}
-
-
-
微信机器人
-
- -
- 配置
- {overview?.wechat?.configured ? '已配置' : '未配置'}
-
- -
- 状态
- {overview?.wechat?.online ? '在线' : '离线'}
-
- -
- 账号
- {overview?.wechat?.nickName || overview?.wechat?.wcId || '—'}
-
-
- {overview?.wechat?.proxyError ?
{overview.wechat.proxyError}
: null}
-
-
-
- {proMode && (
-
-
Pro 转播实况
-
-
YouTube 录制进程
-
- {proLive?.youtubeProcess ?? '—'} · 状态{' '}
- {proLive?.processStatus ?? '—'}
- {proLive?.processOnline ? (
- 运行中
- ) : (
- 未在运行
- )}
-
- {(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
-
-
- {row.channelName || '未命名频道'}{' '}
- key …{row.youtubeKeySuffix || '—'}
-
-
- 主播
- {row.anchor ?? '—'}
-
-
- 源站
-
- {row.douyinHint ?? '—'}
-
-
-
- 标题
-
- {row.streamTitle ?? '—'}
-
-
-
- 已播时长
- {fmtDur(row.durationSec)}
-
-
- ))}
- {!proLive?.relayRows?.length &&
暂无实况
}
-
-
- )}
-
-
-
-
网络快照
- {net ? (
- <>
-
更新 {fmtTs(net.ts)}
-
- >
- ) : (
-
暂无
- )}
-
-
-
本机负载快照
- {sys ? (
- <>
-
更新 {fmtTs(sys.ts)}
-
- >
- ) : (
-
暂无
- )}
-
-
-
-
-
直播与任务事件
-
-
-
-
-
- | 时间 |
- 进程 |
- 类型 |
- 状态 |
- 说明 |
- 详情 |
-
-
-
- {(data?.liveEvents?.length ? data.liveEvents : []).map((row) => (
-
- | {fmtTs(row.ts)} |
- {String(row.process ?? '—')} |
- {String(row.event_type ?? '—')} |
- {String(row.status ?? '—')} |
-
- {String(row.message ?? '—')}
- |
-
- {row.detail_json ? String(row.detail_json).slice(0, 160) : '—'}
- |
-
- ))}
-
-
- {!data?.liveEvents?.length &&
暂无记录
}
-
-
-
-
-
用户操作
-
-
-
-
- | 时间 |
- 操作 |
- 页面 |
- 详情 |
-
-
-
- {(data?.userActions?.length ? data.userActions : []).map((row) => (
-
- | {fmtTs(row.ts)} |
- {String(row.action ?? '—')} |
- {String(row.nav ?? '—')} |
-
- {row.detail_json ? String(row.detail_json).slice(0, 120) : '—'}
- |
-
- ))}
-
-
- {!data?.userActions?.length &&
暂无记录
}
-
-
-
-
-
客户端运行日志
-
-
-
-
- | 时间 |
- 级别 |
- 来源 |
- 内容 |
-
-
-
- {(data?.clientLogs?.length ? data.clientLogs : []).map((row) => (
-
- | {fmtTs(row.ts)} |
- {String(row.level ?? '—')} |
- {String(row.source ?? '—')} |
- {String(row.message ?? '—')} |
-
- ))}
-
-
- {!data?.clientLogs?.length &&
暂无记录
}
-
-
-
- )
-}
diff --git a/electron/src/renderer/src/WorkspaceDeskBackdrop.tsx b/electron/src/renderer/src/WorkspaceDeskBackdrop.tsx
deleted file mode 100644
index 8c6a296..0000000
--- a/electron/src/renderer/src/WorkspaceDeskBackdrop.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import { useEffect, useMemo, useRef } from 'react'
-import * as d3 from 'd3'
-import * as THREE from 'three'
-
-/** 与 /client/overview 的 youtubeChannels 项一致 */
-export type DeskChannel = {
- name: string
- douyinPreview?: string
- keyPreview?: string
-}
-
-export type DeskRelayLive = {
- processOnline?: boolean
- relayRows?: Array<{
- channelId?: string | null
- channelName?: string
- durationSec?: number | null
- }>
-}
-
-type Props = {
- channels: DeskChannel[]
- relayLive: DeskRelayLive | null
- /** 本地 SQLite 近期事件条数,用于无线路配置时的活动强度参考 */
- recentLiveEventCount: number
- peerOnline: number
-}
-
-function cssAccentHex(): string {
- const v = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
- return v || '#5b9dff'
-}
-
-/** 业务:配置完整度 0 / 0.5 / 1(与后台 overview 展示逻辑一致) */
-function channelReadiness(c: DeskChannel): number {
- const d = (c.douyinPreview || '').includes('待填写')
- const k = (c.keyPreview || '').includes('待填写')
- if (!d && !k) return 1
- if (d && k) return 0
- return 0.5
-}
-
-function durationForChannel(name: string, relay: DeskRelayLive | null): number {
- const rows = relay?.relayRows ?? []
- const n = name.trim()
- const hit = rows.find((r) => (r.channelName || '').trim() === n)
- const s = hit?.durationSec
- return typeof s === 'number' && !Number.isNaN(s) ? s : 0
-}
-
-function buildThreeDataset(
- channels: DeskChannel[],
- relay: DeskRelayLive | null,
- recentLiveEventCount: number,
-): { positions: Float32Array; colors: Float32Array; count: number } {
- const list = channels.length ? channels.slice(0, 10) : [{ name: '(暂无线路)', douyinPreview: '(待填写)', keyPreview: '(待填写)' }]
- const rows = list.length
- let total = 0
- const capPerRow: number[] = []
- for (let i = 0; i < rows; i++) {
- const c = list[i]!
- const r = channelReadiness(c)
- const dur = channels.length ? durationForChannel(c.name, relay) : 0
- const n = Math.min(
- 200,
- Math.max(
- 14,
- Math.floor(
- 16 +
- dur / 22 +
- r * 36 +
- (channels.length ? 0 : Math.min(120, recentLiveEventCount / 2)),
- ),
- ),
- )
- capPerRow.push(n)
- total += n
- }
-
- const positions = new Float32Array(total * 3)
- const colors = new Float32Array(total * 3)
- let offset = 0
- const ySpan = Math.max(1, rows - 1)
- for (let i = 0; i < rows; i++) {
- const c = list[i]!
- const r = channelReadiness(c)
- const y = rows === 1 ? 0 : (i / ySpan) * 1.6 - 0.8
- const n = capPerRow[i]!
- const hue = 0.28 + r * 0.22
- const sat = 0.35 + r * 0.45
- const light = 0.42 + r * 0.22
- const base = new THREE.Color().setHSL(hue, sat, light)
- for (let j = 0; j < n; j++) {
- const t = j / Math.max(1, n - 1)
- const x = (t - 0.5) * 5.2 + (Math.random() - 0.5) * 0.12
- const z = (Math.random() - 0.5) * 0.35
- positions[(offset + j) * 3] = x
- positions[(offset + j) * 3 + 1] = y + (Math.random() - 0.5) * 0.08
- positions[(offset + j) * 3 + 2] = z
- const jitter = 0.85 + Math.random() * 0.15
- colors[(offset + j) * 3] = base.r * jitter
- colors[(offset + j) * 3 + 1] = base.g * jitter
- colors[(offset + j) * 3 + 2] = base.b * jitter
- }
- offset += n
- }
- return { positions, colors, count: total }
-}
-
-export default function WorkspaceDeskBackdrop({
- channels,
- relayLive,
- recentLiveEventCount,
- peerOnline,
-}: Props) {
- const threeMountRef = useRef(null)
- const svgRef = useRef(null)
-
- const dataKey = useMemo(
- () =>
- JSON.stringify({
- ch: channels.map((c) => [c.name, c.douyinPreview, c.keyPreview]),
- relay: relayLive?.relayRows?.map((r) => [r.channelName, r.channelId, r.durationSec]),
- ev: recentLiveEventCount,
- peer: peerOnline,
- }),
- [channels, relayLive, recentLiveEventCount, peerOnline],
- )
-
- useEffect(() => {
- const mount = threeMountRef.current
- if (!mount) return
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
-
- let w = mount.clientWidth || 600
- let h = mount.clientHeight || 176
- if (w < 40) w = 600
-
- const { positions, colors, count } = buildThreeDataset(channels, relayLive, recentLiveEventCount)
- if (count === 0) return
-
- const scene = new THREE.Scene()
- const camera = new THREE.PerspectiveCamera(48, w / h, 0.1, 100)
- camera.position.z = 4.8
-
- const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, premultipliedAlpha: false })
- renderer.setClearColor(0x000000, 0)
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
- renderer.setSize(w, h)
- mount.appendChild(renderer.domElement)
-
- const geo = new THREE.BufferGeometry()
- geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
- geo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
- const mat = new THREE.PointsMaterial({
- size: 0.032,
- vertexColors: true,
- transparent: true,
- opacity: 0.88,
- depthWrite: false,
- sizeAttenuation: true,
- })
- const points = new THREE.Points(geo, mat)
- scene.add(points)
-
- let raf = 0
- const t0 = performance.now()
- const tick = () => {
- const t = (performance.now() - t0) * 0.0001
- if (!reduced) {
- points.rotation.y = t * 0.65
- points.rotation.x = Math.sin(t * 0.3) * 0.08
- }
- renderer.render(scene, camera)
- raf = requestAnimationFrame(tick)
- }
- tick()
-
- const ro = new ResizeObserver(() => {
- if (!threeMountRef.current) return
- const ww = threeMountRef.current.clientWidth || 600
- const hh = threeMountRef.current.clientHeight || 176
- camera.aspect = ww / hh
- camera.updateProjectionMatrix()
- renderer.setSize(ww, hh)
- })
- ro.observe(mount)
-
- return () => {
- cancelAnimationFrame(raf)
- ro.disconnect()
- geo.dispose()
- mat.dispose()
- renderer.dispose()
- if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement)
- }
- }, [dataKey])
-
- useEffect(() => {
- const svg = svgRef.current
- if (!svg) return
-
- const w = Math.max(svg.clientWidth || 0, 320)
- const h = Math.max(svg.clientHeight || 0, 168)
- const margin = { top: 14, right: 10, bottom: 36, left: 8 }
- const innerW = w - margin.left - margin.right
- const innerH = h - margin.top - margin.bottom
-
- const list = channels.length
- ? channels.slice(0, 10)
- : [{ name: '暂无线路', douyinPreview: '(待填写)', keyPreview: '(待填写)' }]
- const rows = list.map((c) => ({
- name: c.name.length > 8 ? `${c.name.slice(0, 7)}…` : c.name,
- fullName: c.name,
- r: channelReadiness(c),
- }))
-
- const x = d3
- .scaleBand()
- .domain(rows.map((d) => d.name))
- .range([0, innerW])
- .padding(0.22)
-
- const y = d3.scaleLinear().domain([0, 1]).range([innerH, 0])
-
- const root = d3.select(svg)
- root.selectAll('*').remove()
- const g = root.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
-
- const accent = cssAccentHex()
-
- g.append('text')
- .attr('x', 0)
- .attr('y', -2)
- .attr('fill', accent)
- .attr('font-size', 11)
- .attr('font-weight', 600)
- .text('线路配置完整度')
-
- const barG = g
- .selectAll('g.barcell')
- .data(rows)
- .join('g')
- .attr('class', 'barcell')
-
- barG
- .append('rect')
- .attr('class', 'bar')
- .attr('x', (d) => x(d.name) ?? 0)
- .attr('y', (d) => y(d.r))
- .attr('width', x.bandwidth())
- .attr('height', (d) => innerH - y(d.r))
- .attr('rx', 4)
- .attr('fill', (d) =>
- d.r >= 1 ? 'rgba(61, 214, 140, 0.55)' : d.r >= 0.5 ? 'rgba(246, 173, 85, 0.5)' : 'rgba(139, 146, 158, 0.45)',
- )
- .attr('stroke', accent)
- .attr('stroke-opacity', 0.35)
-
- barG.append('title').text((d) => `${d.fullName} · 完整度 ${(d.r * 100).toFixed(0)}%`)
-
- g.selectAll('text.pct')
- .data(rows)
- .join('text')
- .attr('class', 'pct')
- .attr('x', (d) => (x(d.name) ?? 0) + x.bandwidth() / 2)
- .attr('y', (d) => y(d.r) - 4)
- .attr('text-anchor', 'middle')
- .attr('fill', getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#e8eaed')
- .attr('font-size', 10)
- .text((d) => `${(d.r * 100).toFixed(0)}%`)
-
- g.selectAll('text.xlab')
- .data(rows)
- .join('text')
- .attr('class', 'xlab')
- .attr('x', (d) => (x(d.name) ?? 0) + x.bandwidth() / 2)
- .attr('y', innerH + 14)
- .attr('text-anchor', 'middle')
- .attr('fill', getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() || '#8b929e')
- .attr('font-size', 9)
- .attr('transform', (d) => {
- const cx = (x(d.name) ?? 0) + x.bandwidth() / 2
- const cy = innerH + 14
- return `rotate(-28 ${cx} ${cy})`
- })
- .text((d) => d.name)
-
- return () => {
- root.selectAll('*').remove()
- }
- }, [dataKey])
-
- return (
-
- )
-}
diff --git a/electron/src/renderer/src/WorkspaceEventSparkline.tsx b/electron/src/renderer/src/WorkspaceEventSparkline.tsx
deleted file mode 100644
index d1d2792..0000000
--- a/electron/src/renderer/src/WorkspaceEventSparkline.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useEffect, useMemo, useRef } from 'react'
-import * as echarts from 'echarts/core'
-import { LineChart } from 'echarts/charts'
-import { GridComponent, TooltipComponent } from 'echarts/components'
-import { CanvasRenderer } from 'echarts/renderers'
-
-echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
-
-type LiveRow = { ts?: unknown }
-
-function bucketCounts(rows: LiveRow[], bucketMs: number, spanMs: number): { labels: string[]; values: number[] } {
- const now = Date.now()
- const start = now - spanMs
- const n = Math.max(1, Math.ceil(spanMs / bucketMs))
- const buckets = new Array(n).fill(0)
- for (const r of rows) {
- const t = typeof r.ts === 'number' ? r.ts : 0
- if (t < start || t > now) continue
- const idx = Math.min(n - 1, Math.floor((t - start) / bucketMs))
- buckets[idx] += 1
- }
- const labels = buckets.map((_, i) => {
- const t = new Date(start + i * bucketMs)
- return t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
- })
- return { labels, values: buckets }
-}
-
-export default function WorkspaceEventSparkline({ rows }: { rows: LiveRow[] | undefined }) {
- const ref = useRef(null)
- const chartRef = useRef(null)
-
- const series = useMemo(() => {
- const list = rows?.length ? [...rows] : []
- return bucketCounts(list, 120_000, 72 * 60_000)
- }, [rows])
-
- const applyChart = useMemo(() => {
- return () => {
- const chart = chartRef.current
- if (!chart) return
- const root = getComputedStyle(document.documentElement)
- const accent = root.getPropertyValue('--accent').trim() || '#5b9dff'
- const muted = root.getPropertyValue('--text-muted').trim() || '#8b929e'
- const border = root.getPropertyValue('--border-subtle').trim() || 'rgba(255,255,255,0.06)'
- const text = root.getPropertyValue('--text').trim() || '#e8eaed'
- const tipBg =
- document.documentElement.getAttribute('data-theme') === 'light'
- ? 'rgba(255,255,255,0.96)'
- : 'rgba(22, 24, 30, 0.92)'
-
- chart.setOption({
- animationDuration: 520,
- animationEasing: 'cubicOut',
- grid: { left: 44, right: 12, top: 18, bottom: 28 },
- tooltip: {
- trigger: 'axis',
- backgroundColor: tipBg,
- borderColor: border,
- textStyle: { color: text, fontSize: 12 },
- },
- xAxis: {
- type: 'category',
- data: series.labels,
- axisLine: { lineStyle: { color: border } },
- axisLabel: { color: muted, fontSize: 10, rotate: 32 },
- },
- yAxis: {
- type: 'value',
- minInterval: 1,
- splitLine: { lineStyle: { color: border, type: 'dashed' } },
- axisLabel: { color: muted, fontSize: 10 },
- },
- series: [
- {
- type: 'line',
- data: series.values,
- smooth: true,
- showSymbol: series.values.length <= 24,
- symbolSize: 5,
- lineStyle: { width: 2, color: accent },
- areaStyle: {
- color: {
- type: 'linear',
- x: 0,
- y: 0,
- x2: 0,
- y2: 1,
- colorStops: [
- { offset: 0, color: `${accent}55` },
- { offset: 1, color: `${accent}08` },
- ],
- },
- },
- },
- ],
- })
- }
- }, [series])
-
- useEffect(() => {
- const el = ref.current
- if (!el) return
- const chart = echarts.init(el, undefined, { renderer: 'canvas' })
- chartRef.current = chart
- const ro = new ResizeObserver(() => chart.resize())
- ro.observe(el)
- requestAnimationFrame(() => applyChart())
- return () => {
- ro.disconnect()
- chart.dispose()
- chartRef.current = null
- }
- }, [applyChart])
-
- useEffect(() => {
- applyChart()
- }, [applyChart])
-
- useEffect(() => {
- const mo = new MutationObserver(() => applyChart())
- mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
- return () => mo.disconnect()
- }, [applyChart])
-
- return (
-
- )
-}
diff --git a/electron/src/renderer/src/adaptivePoll.ts b/electron/src/renderer/src/adaptivePoll.ts
new file mode 100644
index 0000000..49fa361
--- /dev/null
+++ b/electron/src/renderer/src/adaptivePoll.ts
@@ -0,0 +1,74 @@
+/** Adaptive polling intervals (from sh2 web-console/lib/adaptivePoll.ts). */
+
+export type MonitorActivity = 'hot' | 'warm' | 'idle'
+
+type RowLike = {
+ process_status?: string
+ business_status?: string
+ is_pushing?: boolean
+}
+
+const HOT_BUSINESS = new Set([
+ 'streaming',
+ 'relay_slow',
+ 'source_live',
+ 'source_stalled',
+ 'youtube_key_rejected',
+ 'error',
+])
+
+const WARM_BUSINESS = new Set(['waiting_source', 'monitoring', 'misconfigured', 'stale', 'relay_starting'])
+
+function pm2Online(status?: string): boolean {
+ return /^(online|running)$/i.test((status || '').trim())
+}
+
+export function classifyRowActivity(row?: RowLike): MonitorActivity {
+ if (!row) return 'idle'
+ const business = (row.business_status || '').trim().toLowerCase()
+ if (row.is_pushing || HOT_BUSINESS.has(business)) return 'hot'
+ if (WARM_BUSINESS.has(business) || pm2Online(row.process_status)) return 'warm'
+ return 'idle'
+}
+
+export function classifyProcessMonitorActivity(rows: RowLike[]): MonitorActivity {
+ let activity: MonitorActivity = 'idle'
+ for (const row of rows) {
+ const rowActivity = classifyRowActivity(row)
+ if (rowActivity === 'hot') return 'hot'
+ if (rowActivity === 'warm') activity = 'warm'
+ }
+ return activity
+}
+
+export const HIDDEN_TAB_POLL_MS = 30_000
+
+export function statusPollMs(options: {
+ activity: MonitorActivity
+ pageVisible: boolean
+ paused?: boolean
+}): number {
+ if (options.paused) return 0
+ if (!options.pageVisible) return HIDDEN_TAB_POLL_MS
+ switch (options.activity) {
+ case 'hot':
+ return 800
+ case 'warm':
+ return 2000
+ default:
+ return 4000
+ }
+}
+
+export function relayLiveStatusPollMs(options: {
+ pushingLanes: number
+ pageVisible: boolean
+}): number {
+ if (!options.pageVisible) return HIDDEN_TAB_POLL_MS
+ return options.pushingLanes > 0 ? 2000 : 5000
+}
+
+export function qualityGuardPollMs(options: { pushingLanes: number; pageVisible: boolean }): number {
+ if (!options.pageVisible) return HIDDEN_TAB_POLL_MS
+ return options.pushingLanes > 0 ? 6000 : 15000
+}
diff --git a/electron/src/renderer/src/api.ts b/electron/src/renderer/src/api.ts
index 70ee0c1..66a8110 100644
--- a/electron/src/renderer/src/api.ts
+++ b/electron/src/renderer/src/api.ts
@@ -107,3 +107,109 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
}
return fetch(input, { ...init, headers, cache: 'no-store' })
}
+
+type JsonErrorPayload = {
+ error?: string
+ message?: string
+ detail?: unknown
+ output?: string
+}
+
+const MAX_ERROR_TEXT = 240
+
+function summarizeResponseText(raw: string): string {
+ const compact = raw.replace(/\s+/g, ' ').trim()
+ if (!compact) return ''
+ if (compact.length <= MAX_ERROR_TEXT) return compact
+ return `${compact.slice(0, MAX_ERROR_TEXT - 3)}...`
+}
+
+function defaultHttpMessage(response: Response): string {
+ const suffix = response.statusText?.trim() ? ` ${response.statusText.trim()}` : ''
+ return `HTTP ${response.status}${suffix}`
+}
+
+function stringifyDetail(detail: unknown): string {
+ if (typeof detail === 'string') return detail.trim()
+ if (Array.isArray(detail)) {
+ const parts = detail
+ .map((item) => {
+ if (typeof item === 'string') return item.trim()
+ if (item && typeof item === 'object' && 'msg' in item) {
+ return String((item as { msg?: unknown }).msg ?? '').trim()
+ }
+ return ''
+ })
+ .filter(Boolean)
+ return parts.join('; ')
+ }
+ if (detail && typeof detail === 'object') {
+ try {
+ return summarizeResponseText(JSON.stringify(detail))
+ } catch {
+ return ''
+ }
+ }
+ return ''
+}
+
+function extractErrorMessage(
+ response: Response,
+ raw: string,
+ payload: JsonErrorPayload | null,
+): string {
+ const candidate =
+ (typeof payload?.error === 'string' && payload.error.trim()) ||
+ (typeof payload?.message === 'string' && payload.message.trim()) ||
+ stringifyDetail(payload?.detail) ||
+ (typeof payload?.output === 'string' && payload.output.trim()) ||
+ summarizeResponseText(raw)
+ return candidate || defaultHttpMessage(response)
+}
+
+/** Typed JSON fetch with unified error messages (sh2 fetchJson pattern). */
+export async function apiFetchJson>(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+): Promise {
+ let response: Response
+ try {
+ response = await apiFetch(input, init)
+ } catch (error) {
+ const message =
+ error instanceof Error && error.message.trim() ? error.message : '网络请求失败'
+ throw new Error(message)
+ }
+
+ const raw = (await response.text()).trim()
+ let payload: (T & JsonErrorPayload) | null = null
+
+ if (raw) {
+ try {
+ payload = JSON.parse(raw) as T & JsonErrorPayload
+ } catch {
+ if (!response.ok) {
+ throw new Error(extractErrorMessage(response, raw, null))
+ }
+ throw new Error('响应不是有效 JSON')
+ }
+ }
+
+ if (!response.ok) {
+ throw new Error(extractErrorMessage(response, raw, payload))
+ }
+
+ return (payload ?? {}) as T
+}
+
+/** 探测 web2 是否已启动(/health 无需鉴权) */
+export async function checkApiHealth(): Promise {
+ try {
+ const response = await apiFetch(apiUrl('/health'))
+ if (!response.ok) return false
+ const data = (await response.json()) as { ok?: boolean }
+ return data.ok === true
+ } catch {
+ return false
+ }
+}
diff --git a/electron/src/renderer/src/components/CameraLocalPreview.tsx b/electron/src/renderer/src/components/CameraLocalPreview.tsx
new file mode 100644
index 0000000..cf42c5a
--- /dev/null
+++ b/electron/src/renderer/src/components/CameraLocalPreview.tsx
@@ -0,0 +1,86 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+type Props = {
+ active: boolean
+}
+
+export default function CameraLocalPreview({ active }: Props) {
+ const videoRef = useRef(null)
+ const streamRef = useRef(null)
+ const [state, setState] = useState<'idle' | 'loading' | 'live' | 'error'>('idle')
+ const [message, setMessage] = useState('')
+
+ const stop = useCallback(() => {
+ streamRef.current?.getTracks().forEach((t) => t.stop())
+ streamRef.current = null
+ if (videoRef.current) videoRef.current.srcObject = null
+ setState('idle')
+ }, [])
+
+ const start = useCallback(async () => {
+ stop()
+ setMessage('')
+ setState('loading')
+ try {
+ if (!navigator.mediaDevices?.getUserMedia) {
+ throw new Error('当前环境不支持摄像头预览')
+ }
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
+ audio: false,
+ })
+ streamRef.current = stream
+ const el = videoRef.current
+ if (el) {
+ el.srcObject = stream
+ await el.play()
+ }
+ setState('live')
+ } catch (e) {
+ setState('error')
+ setMessage(e instanceof Error ? e.message : String(e))
+ }
+ }, [stop])
+
+ useEffect(() => {
+ if (!active) {
+ stop()
+ return
+ }
+ void start()
+ return stop
+ }, [active, start, stop])
+
+ return (
+
+
+ {state !== 'live' ? (
+
+ {state === 'loading'
+ ? '正在打开摄像头…'
+ : state === 'error'
+ ? message || '无法打开摄像头,请检查权限或是否被其他软件占用'
+ : '点击「打开预览」查看本机画面'}
+
+ ) : null}
+
+
+ {state === 'live' ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/electron/src/renderer/src/components/ConfigBackupsInline.tsx b/electron/src/renderer/src/components/ConfigBackupsInline.tsx
new file mode 100644
index 0000000..ad83da8
--- /dev/null
+++ b/electron/src/renderer/src/components/ConfigBackupsInline.tsx
@@ -0,0 +1,88 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import { apiFetchJson, apiUrl } from '../api'
+
+type BackupRow = {
+ id: string
+ label: string
+ created_at?: string
+ reason?: string
+}
+
+type Props = {
+ configFile: string
+ label?: string
+}
+
+export default function ConfigBackupsInline({ configFile, label }: Props) {
+ const [open, setOpen] = useState(false)
+ const [backups, setBackups] = useState([])
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+
+ const load = useCallback(async () => {
+ if (!configFile) return
+ try {
+ const data = await apiFetchJson<{ backups?: BackupRow[] }>(
+ apiUrl(`/config/backups?file=${encodeURIComponent(configFile)}`),
+ )
+ setBackups(data.backups ?? [])
+ } catch {
+ setBackups([])
+ }
+ }, [configFile])
+
+ useEffect(() => {
+ if (open) void load()
+ }, [open, load])
+
+ const restore = async (backupId: string) => {
+ if (!window.confirm('确定恢复该备份?当前文件会先自动备份。')) return
+ setBusy(true)
+ setMsg('')
+ try {
+ const data = await apiFetchJson<{ message?: string }>(apiUrl('/config/restore'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ file: configFile, backupId }),
+ })
+ setMsg(data.message || '已恢复')
+ await load()
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (!configFile) return null
+
+ return (
+
+
+ {open ? (
+
+ {msg ?
{msg}
: null}
+ {backups.length === 0 ? (
+
保存配置后会自动创建备份。
+ ) : (
+
+ {backups.map((b) => (
+ -
+ {b.created_at || b.label}
+ {b.reason}
+
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/LivePipeline.tsx b/electron/src/renderer/src/components/LivePipeline.tsx
new file mode 100644
index 0000000..64f63b7
--- /dev/null
+++ b/electron/src/renderer/src/components/LivePipeline.tsx
@@ -0,0 +1,125 @@
+type StepState = 'idle' | 'pending' | 'active' | 'warn' | 'error'
+
+type Props = {
+ businessStatus?: string
+ isPushing?: boolean
+}
+
+function StepIconSource() {
+ return (
+
+ )
+}
+
+function StepIconEncode() {
+ return (
+
+ )
+}
+
+function StepIconYoutube() {
+ return (
+
+ )
+}
+
+function FlowArrow({ active }: { active: boolean }) {
+ return (
+
+ )
+}
+
+const STEPS = [
+ { key: 'source', tone: 'source', icon: StepIconSource, kicker: '源站', title: '直播间地址' },
+ { key: 'encode', tone: 'encode', icon: StepIconEncode, kicker: '本机采集', title: 'FFmpeg / GPU' },
+ { key: 'youtube', tone: 'youtube', icon: StepIconYoutube, kicker: 'YouTube', title: 'RTMP 推流' },
+] as const
+
+function resolveStepStates(businessStatus?: string, isPushing?: boolean): Record {
+ const s = (businessStatus || '').trim().toLowerCase()
+ const idle: StepState = 'idle'
+ const pending: StepState = 'pending'
+ const active: StepState = 'active'
+ const warn: StepState = 'warn'
+ const error: StepState = 'error'
+
+ if (!s || s === 'inactive' || s === 'stopped') {
+ return { source: idle, encode: idle, youtube: idle }
+ }
+ if (s === 'misconfigured') {
+ return { source: warn, encode: idle, youtube: warn }
+ }
+ if (s === 'waiting_source' || s === 'monitoring') {
+ return { source: pending, encode: idle, youtube: idle }
+ }
+ if (s === 'source_live') {
+ return { source: active, encode: pending, youtube: idle }
+ }
+ if (s === 'relay_starting') {
+ return { source: active, encode: pending, youtube: pending }
+ }
+ if (s === 'streaming') {
+ return { source: active, encode: active, youtube: active }
+ }
+ if (s === 'relay_slow' || s === 'source_stalled') {
+ return { source: active, encode: warn, youtube: warn }
+ }
+ if (s === 'youtube_key_rejected') {
+ return { source: active, encode: error, youtube: error }
+ }
+ if (s === 'stream_disconnected') {
+ return { source: active, encode: error, youtube: error }
+ }
+ if (s === 'stale' || s === 'error') {
+ return { source: warn, encode: warn, youtube: warn }
+ }
+ if (isPushing) {
+ return { source: active, encode: active, youtube: active }
+ }
+ return { source: pending, encode: idle, youtube: idle }
+}
+
+export default function LivePipeline({ businessStatus, isPushing }: Props) {
+ const states = resolveStepStates(businessStatus, isPushing)
+
+ return (
+
+ {STEPS.map((step, i) => {
+ const state = states[step.key] ?? 'idle'
+ const prevActive = i > 0 && (states[STEPS[i - 1].key] === 'active' || states[STEPS[i - 1].key] === 'warn')
+ return (
+
+ {i > 0 ?
: null}
+
+
+
+
+
+ {step.kicker}
+ {step.title}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx b/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx
new file mode 100644
index 0000000..72e6065
--- /dev/null
+++ b/electron/src/renderer/src/components/LiveYoutubeMonitor.tsx
@@ -0,0 +1,287 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval'
+
+type RelayWatchItem = { relayId: string; label: string; input: string }
+
+type LiveSessionLite = {
+ videoId: string
+ title: string
+ status: string
+ viewerCount?: number
+ peakViewerCount?: number
+ channelName?: string
+ watchId: string
+ lastCheckedAt?: string
+}
+
+type ChatMsgLite = { id: string; author: string; text: string; timestamp: string }
+
+type Props = {
+ enabled: boolean
+ relayItems: RelayWatchItem[]
+ activeRelayId?: string | null
+ isPushing?: boolean
+ onKeywordAlert?: (text: string) => void
+}
+
+const PUSH_MISMATCH_MS = 45_000
+
+export default function LiveYoutubeMonitor({
+ enabled,
+ relayItems,
+ activeRelayId,
+ isPushing = false,
+ onKeywordAlert,
+}: Props) {
+ const pageVisible = usePageVisible()
+ const [ready, setReady] = useState(false)
+ const [monitorOn, setMonitorOn] = useState(false)
+ const [sessions, setSessions] = useState([])
+ const [watchRelayMap, setWatchRelayMap] = useState>({})
+ const [chat, setChat] = useState([])
+ const [syncMsg, setSyncMsg] = useState('')
+ const [err, setErr] = useState(null)
+
+ const pushMismatchNotifiedRef = useRef(false)
+ const pushMismatchTimerRef = useRef | null>(null)
+ const wasLiveRef = useRef(false)
+
+ const api = typeof window !== 'undefined' ? window.youlive : undefined
+
+ const configuredCount = useMemo(
+ () => relayItems.filter((i) => i.input.trim()).length,
+ [relayItems],
+ )
+
+ const activeSession = useMemo(() => {
+ const pickForRelay = (relayId: string | null | undefined) => {
+ if (!relayId) return undefined
+ return sessions.find(
+ (s) => watchRelayMap[s.watchId] === relayId && (s.status === 'live' || s.status === 'upcoming'),
+ )
+ }
+ if (activeRelayId) {
+ return pickForRelay(activeRelayId) ?? sessions.find((s) => watchRelayMap[s.watchId] === activeRelayId)
+ }
+ return sessions.find((s) => s.status === 'live') ?? sessions[0]
+ }, [sessions, activeRelayId, watchRelayMap])
+
+ const notifyDesktop = useCallback(
+ (title: string, body: string) => {
+ void api?.notifyDesktop?.(title, body)
+ },
+ [api],
+ )
+
+ const syncWatches = useCallback(async () => {
+ if (!api || !enabled) return
+ try {
+ const r = await api.syncRelayWatches(relayItems.filter((i) => i.input.trim()))
+ setSyncMsg(r.synced > 0 ? `已同步 ${r.synced} 个 YouTube 监听目标` : '未配置 YouTube 频道地址')
+ setErr(null)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [api, enabled, relayItems])
+
+ const refresh = useCallback(async () => {
+ if (!api || !enabled) return
+ try {
+ const [st, ss, watches] = await Promise.all([
+ api.monitor.status(),
+ api.sessions.get(),
+ api.watch.list(),
+ ])
+ setMonitorOn(st.running)
+ setSessions(ss as LiveSessionLite[])
+ const map: Record = {}
+ for (const w of watches) {
+ const m = /\[relay:([^\]]+)\]/.exec(w.label)
+ if (m) map[w.watchId] = m[1]
+ }
+ setWatchRelayMap(map)
+ setErr(null)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [api, enabled])
+
+ useEffect(() => {
+ setReady(!!api)
+ }, [api])
+
+ useEffect(() => {
+ if (!enabled || !api) return
+ void (async () => {
+ await syncWatches()
+ const st = await api.monitor.status()
+ if (!st.running) await api.monitor.start()
+ await refresh()
+ })()
+ }, [enabled, api, syncWatches, refresh])
+
+ useEffect(() => {
+ if (!api || !enabled) return
+ return api.monitor.onEvent((event) => {
+ if (event.type === 'chat-keyword' && onKeywordAlert) {
+ const p = event.payload as { author?: string; text?: string; keyword?: string }
+ const text = `${p.author ?? '?'}: ${p.text ?? ''} [${p.keyword ?? '关键词'}]`
+ onKeywordAlert(text)
+ notifyDesktop('🔔 弹幕关键词', text)
+ }
+ if (
+ event.type === 'session-live' ||
+ event.type === 'session-offline' ||
+ event.type === 'session-update'
+ ) {
+ void refresh()
+ }
+ if (event.type === 'chat-message' && event.videoId === activeSession?.videoId) {
+ const m = event.payload as ChatMsgLite
+ setChat((prev) => [...prev.slice(-49), m])
+ }
+ })
+ }, [api, enabled, onKeywordAlert, notifyDesktop, refresh, activeSession?.videoId])
+
+ useEffect(() => {
+ if (!api || !activeSession?.videoId) {
+ setChat([])
+ return
+ }
+ void api.chat.history(activeSession.videoId).then((rows) => {
+ setChat((rows as ChatMsgLite[]).slice(-30))
+ })
+ }, [api, activeSession?.videoId])
+
+ useAdaptiveInterval(() => void refresh(), pageVisible && enabled ? 8000 : 0, enabled)
+
+ const activeIsLive = activeSession?.status === 'live'
+
+ useEffect(() => {
+ if (pushMismatchTimerRef.current) {
+ clearTimeout(pushMismatchTimerRef.current)
+ pushMismatchTimerRef.current = null
+ }
+ if (!enabled || !isPushing || configuredCount === 0) {
+ pushMismatchNotifiedRef.current = false
+ return
+ }
+ if (activeIsLive) {
+ pushMismatchNotifiedRef.current = false
+ return
+ }
+ pushMismatchTimerRef.current = setTimeout(() => {
+ if (pushMismatchNotifiedRef.current) return
+ pushMismatchNotifiedRef.current = true
+ notifyDesktop(
+ '⚠️ 推流未到达 YouTube',
+ '本机在推流,但目标频道尚未检测到直播。请确认串流密钥、监听地址与 Studio 状态。',
+ )
+ }, PUSH_MISMATCH_MS)
+ return () => {
+ if (pushMismatchTimerRef.current) {
+ clearTimeout(pushMismatchTimerRef.current)
+ pushMismatchTimerRef.current = null
+ }
+ }
+ }, [enabled, isPushing, configuredCount, activeIsLive, activeSession?.videoId, notifyDesktop])
+
+ useEffect(() => {
+ if (!enabled) return
+ if (isPushing && wasLiveRef.current && !activeIsLive) {
+ notifyDesktop(
+ '⏹ YouTube 已下播',
+ '目标端直播已结束,本机可能仍在推流。请检查 Studio 或停止转播进程。',
+ )
+ }
+ wasLiveRef.current = activeIsLive
+ }, [enabled, isPushing, activeIsLive, notifyDesktop])
+
+ if (!enabled) return null
+
+ if (!ready) {
+ return (
+
+ YouTube 监听引擎尚未就绪,请重启应用后再试。
+
+ )
+ }
+
+ const liveCount = sessions.filter((s) => s.status === 'live').length
+ const viewers = activeSession?.viewerCount ?? activeSession?.peakViewerCount
+ const pushTargetMismatch = isPushing && configuredCount > 0 && liveCount === 0
+
+ return (
+
+
+
+
YouTube 目标端监控
+
+ 与转播线路绑定:监听 YouTube 端是否真正开播、观众数与弹幕关键词,辅助判断推流是否到达目标直播间。
+
+
+
+ {monitorOn ? '监听中' : '未启动'}
+
+
+
+ {pushTargetMismatch ? (
+
+ ⚠️ 本机在推流,但 YouTube 端尚未检测到直播(约 45 秒后将发送桌面通知)。
+
+ ) : null}
+
+ {err ? {err}
: null}
+ {syncMsg ? {syncMsg}
: null}
+
+
+
+ 已绑定频道
+ {configuredCount}
+
+
+ YouTube 直播中
+ {liveCount}
+
+
+ 当前观众
+ {viewers != null ? viewers.toLocaleString() : '—'}
+
+
+ 本机推流
+ {isPushing ? '进行中' : '未推流'}
+
+
+
+ {activeSession ? (
+
+
+
+ {activeSession.title || '未命名直播'}
+
+ {activeSession.channelName ? `${activeSession.channelName} · ` : ''}
+ {activeSession.status}
+
+
+
+ {chat.length === 0 ? (
+ - 暂无弹幕(目标端未开播或未关联频道地址)
+ ) : (
+ chat.slice(-8).map((m) => (
+ -
+ {m.author}
+ {m.text}
+
+ ))
+ )}
+
+
+ ) : (
+
+ 请在下方填写各线路的 YouTube 频道/直播页地址,保存后会自动加入监听。
+
+ )}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/QualityGuardBanner.tsx b/electron/src/renderer/src/components/QualityGuardBanner.tsx
new file mode 100644
index 0000000..6d18b7f
--- /dev/null
+++ b/electron/src/renderer/src/components/QualityGuardBanner.tsx
@@ -0,0 +1,154 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import { apiFetchJson, apiUrl } from '../api'
+import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval'
+import { qualityGuardPollMs } from '../adaptivePoll'
+import ScoreRing from './ui/ScoreRing'
+
+type GuardIssue = { level: string; text: string }
+type DiagnosticLayer = {
+ ok?: boolean
+ hint?: string
+ watch_url?: string
+ pushing?: boolean
+}
+type DiagnosticLane = {
+ process: string
+ label: string
+ score: number
+ is_pushing?: boolean
+ issues?: GuardIssue[]
+ layers?: {
+ source?: DiagnosticLayer
+ ffmpeg?: DiagnosticLayer
+ target?: DiagnosticLayer
+ }
+}
+type DiagnosticsReport = {
+ score: number
+ summary?: { pushing_lanes?: number; lanes?: number }
+ lanes?: DiagnosticLane[]
+ recommendations?: GuardIssue[]
+ diagnostics?: {
+ summary?: {
+ source_configured?: number
+ target_configured?: number
+ ffmpeg_active?: number
+ }
+ }
+}
+
+function qualityGuardPollInterval(pushing: number, visible: boolean): number {
+ return qualityGuardPollMs({ pushingLanes: pushing, pageVisible: visible })
+}
+
+function layerLabel(key: 'source' | 'ffmpeg' | 'target'): string {
+ if (key === 'source') return '源站'
+ if (key === 'ffmpeg') return '推流'
+ return '目标'
+}
+
+type Props = {
+ process: string
+ enabled: boolean
+}
+
+export default function QualityGuardBanner({ process, enabled }: Props) {
+ const pageVisible = usePageVisible()
+ const [report, setReport] = useState(null)
+ const [err, setErr] = useState(null)
+
+ const load = useCallback(async () => {
+ if (!enabled) return
+ try {
+ const q = process ? `?process=${encodeURIComponent(process)}` : ''
+ const data = await apiFetchJson(apiUrl(`/youtube/diagnostics${q}`))
+ setReport(data)
+ setErr(null)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [enabled, process])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ const pushing = report?.summary?.pushing_lanes ?? 0
+ const pollMs = useMemo(
+ () => qualityGuardPollInterval(pushing, pageVisible),
+ [pushing, pageVisible],
+ )
+
+ useAdaptiveInterval(() => void load(), pollMs, enabled && pollMs > 0)
+
+ if (!enabled) return null
+
+ const score = report?.score ?? 0
+ const tone = score >= 80 ? 'ok' : score >= 50 ? 'warn' : 'bad'
+ const topIssues = (report?.lanes ?? [])
+ .flatMap((lane) => (lane.issues ?? []).map((i) => ({ ...i, lane: lane.label })))
+ .filter((i) => i.level !== 'ok')
+ .slice(0, 3)
+
+ const primaryLane = (report?.lanes ?? [])[0]
+ const layers = primaryLane?.layers
+
+ return (
+
+
+
+
+ 链路诊断
+ {report ? `${score} 分` : '采集中…'}
+ {report?.summary ? (
+
+ {report.summary.pushing_lanes ?? 0}/{report.summary.lanes ?? 0} 路推流中
+
+ ) : null}
+
+
+ {layers ? (
+
+ {(['source', 'ffmpeg', 'target'] as const).map((key) => {
+ const layer = layers[key]
+ const ok = !!layer?.ok
+ return (
+
+ {layerLabel(key)}
+ {ok ? '正常' : '待检查'}
+
+ )
+ })}
+
+ ) : null}
+
+ {err ?
⚠️ {err}
: null}
+ {!err && topIssues.length > 0 ? (
+
+ {topIssues.map((item, i) => (
+ -
+ {item.level === 'critical' ? '🔴 ' : '⚠️ '}
+ {item.lane ? `[${item.lane}] ` : ''}
+ {item.text}
+
+ ))}
+
+ ) : null}
+ {!err && topIssues.length === 0 && report ? (
+
✅ 源站、推流与目标端检查通过,继续监控即可。
+ ) : null}
+
+
+ )
+}
diff --git a/electron/src/renderer/src/components/RelayLogPanel.tsx b/electron/src/renderer/src/components/RelayLogPanel.tsx
new file mode 100644
index 0000000..817e064
--- /dev/null
+++ b/electron/src/renderer/src/components/RelayLogPanel.tsx
@@ -0,0 +1,209 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import { apiFetchJson, apiUrl } from '../api'
+import { formatDurationCn } from '../liveStatusLabels'
+import { useAdaptiveInterval, usePageVisible } from '../hooks/useAdaptiveInterval'
+
+type RelaySession = {
+ id: number
+ process: string
+ anchor_name?: string
+ source_room_id?: string
+ source_url?: string
+ duration_seconds?: number
+ status?: string
+ end_reason?: string
+ push_date?: string
+}
+
+type StatusFilter = '' | 'active' | 'ended'
+
+type Props = {
+ process: string
+ enabled: boolean
+}
+
+const PAGE_SIZE = 15
+
+export default function RelayLogPanel({ process, enabled }: Props) {
+ const pageVisible = usePageVisible()
+ const [sessions, setSessions] = useState([])
+ const [total, setTotal] = useState(0)
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [err, setErr] = useState(null)
+ const [search, setSearch] = useState('')
+ const [searchDraft, setSearchDraft] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+ const [offset, setOffset] = useState(0)
+
+ const load = useCallback(async () => {
+ if (!enabled) return
+ try {
+ const q = new URLSearchParams({
+ limit: String(PAGE_SIZE),
+ offset: String(offset),
+ })
+ if (process) q.set('process', process)
+ if (search.trim()) q.set('q', search.trim())
+ if (statusFilter) q.set('status', statusFilter)
+ const data = await apiFetchJson<{ sessions?: RelaySession[]; total?: number }>(
+ apiUrl(`/youtube/relay/sessions?${q}`),
+ )
+ setSessions(data.sessions ?? [])
+ setTotal(data.total ?? 0)
+ setErr(null)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [enabled, process, search, statusFilter, offset])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ useAdaptiveInterval(() => void load(), pageVisible && enabled ? 12_000 : 0, enabled)
+
+ const importLog = useCallback(async () => {
+ if (!process) {
+ setMsg('请先选择进程后再导入日志')
+ return
+ }
+ setBusy(true)
+ setMsg('')
+ try {
+ const data = await apiFetchJson<{ imported?: number; error?: string }>(
+ `${apiUrl('/youtube/relay/sessions/import')}?process=${encodeURIComponent(process)}`,
+ { method: 'POST' },
+ )
+ setMsg(`已从 PM2 日志导入 ${data.imported ?? 0} 条会话记录`)
+ setOffset(0)
+ await load()
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setBusy(false)
+ }
+ }, [process, load])
+
+ const applySearch = () => {
+ setOffset(0)
+ setSearch(searchDraft.trim())
+ }
+
+ const activeCount = sessions.filter((s) => s.status === 'active').length
+ const pageStart = total === 0 ? 0 : offset + 1
+ const pageEnd = Math.min(offset + sessions.length, total)
+
+ if (!enabled) return null
+
+ return (
+
+
+
+
+ 📜
+ 转播历史
+
+
+ 自动记录 PM2 在线/离线与会话;支持搜索、筛选与分页。
+ {statusFilter === 'active' && activeCount > 0 ? ` 当前页 ${activeCount} 条进行中。` : ''}
+
+
+
+
+
+
+
+
+
+ setSearchDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') applySearch()
+ }}
+ />
+
+
+
+
+ {err ? {err}
: null}
+ {msg ? {msg}
: null}
+ {sessions.length === 0 ? (
+ 暂无会话记录(启动直播后会自动写入)。
+ ) : (
+
+ {sessions.map((s) => (
+ -
+
+ {s.anchor_name || s.source_room_id || '未知源站'}
+
+ {s.process}
+ {s.push_date ? ` · ${s.push_date}` : ''}
+ {s.source_url ? ` · ${s.source_url}` : ''}
+
+
+
+ {formatDurationCn(s.duration_seconds ?? 0)}
+
+ {s.status === 'active' ? '进行中' : s.end_reason || '已结束'}
+
+
+
+ ))}
+
+ )}
+ {total > 0 ? (
+
+
+ 第 {pageStart}–{pageEnd} 条,共 {total} 条
+
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/RelayValidationBanner.tsx b/electron/src/renderer/src/components/RelayValidationBanner.tsx
new file mode 100644
index 0000000..361ca8d
--- /dev/null
+++ b/electron/src/renderer/src/components/RelayValidationBanner.tsx
@@ -0,0 +1,31 @@
+type Props = {
+ errors: string[]
+ notes?: string[]
+}
+
+export default function RelayValidationBanner({ errors, notes = [] }: Props) {
+ if (!errors.length && !notes.length) return null
+
+ return (
+
+ {errors.length > 0 ? (
+
+
转播配对冲突
+
+ {errors.map((e) => (
+ - {e}
+ ))}
+
+
请修正后再启动对应线路,避免同一抖音地址或同一 YouTube 密钥被多条线路共用。
+
+ ) : null}
+ {notes.length > 0 ? (
+
+ {notes.map((n) => (
+ - {n}
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/SidebarBrand.tsx b/electron/src/renderer/src/components/SidebarBrand.tsx
new file mode 100644
index 0000000..dd3d9b5
--- /dev/null
+++ b/electron/src/renderer/src/components/SidebarBrand.tsx
@@ -0,0 +1,152 @@
+import type { PbUserSnapshot } from '../api'
+
+export type AppTheme = 'light' | 'dark'
+
+export function readInitialTheme(): AppTheme {
+ try {
+ const d = document.documentElement.getAttribute('data-theme')
+ if (d === 'light' || d === 'dark') return d
+ const x = localStorage.getItem('theme')
+ if (x === 'light' || x === 'dark') return x
+ } catch {
+ /* ignore */
+ }
+ return 'dark'
+}
+
+function ThemeIconSun() {
+ return (
+
+ )
+}
+
+function ThemeIconMoon() {
+ return (
+
+ )
+}
+
+function SidebarBrandLogoIcon() {
+ return (
+
+ )
+}
+
+function pbUserInitials(u: PbUserSnapshot): string {
+ const n = (u.name || '').trim()
+ if (n.length >= 2) return n.slice(0, 2).toUpperCase()
+ if (n.length === 1) return n.toUpperCase()
+ const e = (u.email || '').trim()
+ if (e.length >= 2) return e.slice(0, 2).toUpperCase()
+ return '?'
+}
+
+type Props = {
+ tag: string
+ theme: AppTheme
+ applyTheme: (t: AppTheme) => void
+ showAuth: boolean
+ pbUser: PbUserSnapshot | null
+ onLogout: () => void
+ onLogin: () => void
+ onRegister: () => void
+}
+
+export default function SidebarBrand({
+ tag,
+ theme,
+ applyTheme,
+ showAuth,
+ pbUser,
+ onLogout,
+ onLogin,
+ onRegister,
+}: Props) {
+ const showMember = !!(pbUser && (pbUser.email || pbUser.name))
+ const displayName = (pbUser?.name || '').trim() || (pbUser?.email || '').split('@')[0] || '用户'
+
+ return (
+
+
+
+
+
+
+ 无人直播助手
+
+
+
+
+ {tag ?
{tag}
: null}
+ {showAuth &&
+ (showMember && pbUser ? (
+
+
+
+ {pbUserInitials(pbUser)}
+
+
+
+ {displayName}
+
+
+
+
+
+ ) : (
+
+ ))}
+
+ )
+}
diff --git a/electron/src/renderer/src/components/icons/NavIcons.tsx b/electron/src/renderer/src/components/icons/NavIcons.tsx
new file mode 100644
index 0000000..e7af2d7
--- /dev/null
+++ b/electron/src/renderer/src/components/icons/NavIcons.tsx
@@ -0,0 +1,68 @@
+type NavId = 'record' | 'camera' | 'network' | 'system'
+
+type IconProps = { className?: string }
+
+function IconRecord({ className }: IconProps) {
+ return (
+
+ )
+}
+
+function IconCamera({ className }: IconProps) {
+ return (
+
+ )
+}
+
+function IconNetwork({ className }: IconProps) {
+ return (
+
+ )
+}
+
+function IconSystem({ className }: IconProps) {
+ return (
+
+ )
+}
+
+const NAV_ICON_MAP: Record JSX.Element> = {
+ record: IconRecord,
+ camera: IconCamera,
+ network: IconNetwork,
+ system: IconSystem,
+}
+
+export function NavIcon({ id, className }: { id: NavId; className?: string }) {
+ const C = NAV_ICON_MAP[id] ?? IconRecord
+ return
+}
+
+export { IconRecord, IconCamera, IconNetwork, IconSystem }
diff --git a/electron/src/renderer/src/components/ui/LivePulse.tsx b/electron/src/renderer/src/components/ui/LivePulse.tsx
new file mode 100644
index 0000000..7bf31ee
--- /dev/null
+++ b/electron/src/renderer/src/components/ui/LivePulse.tsx
@@ -0,0 +1,15 @@
+type Props = {
+ active?: boolean
+ tone?: 'live' | 'warn' | 'idle'
+ className?: string
+}
+
+export default function LivePulse({ active = true, tone = 'live', className = '' }: Props) {
+ if (!active) return null
+ return (
+
+
+
+
+ )
+}
diff --git a/electron/src/renderer/src/components/ui/MetricBar.tsx b/electron/src/renderer/src/components/ui/MetricBar.tsx
new file mode 100644
index 0000000..f71ee2e
--- /dev/null
+++ b/electron/src/renderer/src/components/ui/MetricBar.tsx
@@ -0,0 +1,46 @@
+type Tone = 'ok' | 'warn' | 'bad' | 'neutral'
+
+function toneFromPercent(p: number): Tone {
+ if (p >= 85) return 'bad'
+ if (p >= 65) return 'warn'
+ return 'ok'
+}
+
+type Props = {
+ value: number | null | undefined
+ max?: number
+ tone?: Tone
+ autoTone?: boolean
+ label?: string
+ className?: string
+}
+
+export default function MetricBar({
+ value,
+ max = 100,
+ tone,
+ autoTone = true,
+ label,
+ className = '',
+}: Props) {
+ const raw = value != null && Number.isFinite(value) ? value : 0
+ const pct = Math.max(0, Math.min(100, max > 0 ? (raw / max) * 100 : 0))
+ const resolvedTone = tone ?? (autoTone ? toneFromPercent(pct) : 'neutral')
+
+ return (
+
+ )
+}
+
+export { toneFromPercent }
diff --git a/electron/src/renderer/src/components/ui/NetSpeedBars.tsx b/electron/src/renderer/src/components/ui/NetSpeedBars.tsx
new file mode 100644
index 0000000..caf0583
--- /dev/null
+++ b/electron/src/renderer/src/components/ui/NetSpeedBars.tsx
@@ -0,0 +1,32 @@
+type Props = {
+ upMbps?: number | null
+ downMbps?: number | null
+ className?: string
+}
+
+function barWidth(mbps: number | null | undefined, cap = 100): number {
+ if (mbps == null || !Number.isFinite(mbps) || mbps <= 0) return 0
+ return Math.min(100, (mbps / cap) * 100)
+}
+
+export default function NetSpeedBars({ upMbps, downMbps, className = '' }: Props) {
+ const upW = barWidth(upMbps)
+ const downW = barWidth(downMbps)
+
+ return (
+
+ )
+}
diff --git a/electron/src/renderer/src/components/ui/ScoreRing.tsx b/electron/src/renderer/src/components/ui/ScoreRing.tsx
new file mode 100644
index 0000000..5dfbbc3
--- /dev/null
+++ b/electron/src/renderer/src/components/ui/ScoreRing.tsx
@@ -0,0 +1,70 @@
+type Tone = 'ok' | 'warn' | 'bad' | 'neutral'
+
+const TONE_COLORS: Record = {
+ ok: 'var(--success)',
+ warn: 'var(--warn)',
+ bad: 'var(--danger)',
+ neutral: 'var(--accent)',
+}
+
+type Props = {
+ value: number
+ max?: number
+ size?: number
+ stroke?: number
+ tone?: Tone
+ label?: string
+ className?: string
+}
+
+export default function ScoreRing({
+ value,
+ max = 100,
+ size = 56,
+ stroke = 5,
+ tone = 'neutral',
+ label,
+ className = '',
+}: Props) {
+ const r = (size - stroke) / 2
+ const c = 2 * Math.PI * r
+ const pct = Math.max(0, Math.min(1, max > 0 ? value / max : 0))
+ const dash = c * pct
+ const color = TONE_COLORS[tone]
+
+ return (
+
+
+
+ {Number.isFinite(value) ? Math.round(value) : '—'}
+
+
+ )
+}
diff --git a/electron/src/renderer/src/hooks/useAdaptiveInterval.ts b/electron/src/renderer/src/hooks/useAdaptiveInterval.ts
new file mode 100644
index 0000000..e55c07e
--- /dev/null
+++ b/electron/src/renderer/src/hooks/useAdaptiveInterval.ts
@@ -0,0 +1,41 @@
+import { useEffect, useRef, useState } from 'react'
+
+import type { MonitorActivity } from '../adaptivePoll'
+
+export function useAdaptiveInterval(
+ callback: () => void,
+ intervalMs: number,
+ enabled = true,
+): void {
+ const callbackRef = useRef(callback)
+ callbackRef.current = callback
+
+ useEffect(() => {
+ if (!enabled || intervalMs <= 0) return
+
+ let timer = 0
+ const tick = () => {
+ callbackRef.current()
+ timer = window.setTimeout(tick, intervalMs)
+ }
+ timer = window.setTimeout(tick, intervalMs)
+ return () => window.clearTimeout(timer)
+ }, [intervalMs, enabled])
+}
+
+/** Track document visibility for adaptive polling backoff. */
+export function usePageVisible(): boolean {
+ const [visible, setVisible] = useState(
+ () => typeof document !== 'undefined' && document.visibilityState === 'visible',
+ )
+
+ useEffect(() => {
+ const onChange = () => setVisible(document.visibilityState === 'visible')
+ document.addEventListener('visibilitychange', onChange)
+ return () => document.removeEventListener('visibilitychange', onChange)
+ }, [])
+
+ return visible
+}
+
+export type { MonitorActivity }
diff --git a/electron/src/renderer/src/liveStatusLabels.ts b/electron/src/renderer/src/liveStatusLabels.ts
new file mode 100644
index 0000000..5119725
--- /dev/null
+++ b/electron/src/renderer/src/liveStatusLabels.ts
@@ -0,0 +1,65 @@
+export type StatusVariant =
+ | 'loading'
+ | 'online'
+ | 'stopped'
+ | 'error'
+ | 'warn'
+ | 'offline'
+ | 'unknown'
+ | 'empty'
+
+/** PM2 进程状态 → 用户可读文案 */
+export function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } {
+ if (processStatus === 'online') return { line: '正在直播', variant: 'online' }
+ if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' }
+ if (processStatus === 'errored') return { line: '出现异常', variant: 'error' }
+ if (processStatus === 'launching' || processStatus === 'starting')
+ return { line: '正在启动…', variant: 'warn' }
+ if (processStatus === 'waiting restart') return { line: '等待重启', variant: 'warn' }
+ if (processStatus === 'not_found') return { line: '尚未开始', variant: 'offline' }
+ if (processStatus === 'invalid') return { line: '无法识别', variant: 'error' }
+ return { line: '状态未知', variant: 'unknown' }
+}
+
+const BUSINESS_LABELS: Record = {
+ streaming: '正在推流到 YouTube',
+ relay_slow: '推流速度偏低',
+ relay_starting: 'FFmpeg 启动中',
+ source_stalled: '推流进度停滞',
+ youtube_key_rejected: 'YouTube 密钥被拒绝',
+ waiting_source: '等待源站开播',
+ source_live: '源站已开播,等待 FFmpeg 推流',
+ monitoring: '进程在线,正在监测源站',
+ misconfigured: '配置异常',
+ stale: '日志心跳过期',
+ stream_disconnected: '推流连接已断开',
+ error: '近期有异常',
+ stopped: '未运行',
+ inactive: '未运行',
+}
+
+export function businessStatusLabel(raw?: string, note?: string): string {
+ const s = (raw || '').trim().toLowerCase()
+ if (s && BUSINESS_LABELS[s]) return BUSINESS_LABELS[s]
+ return note?.trim() || '暂无业务态'
+}
+
+export function formatDurationCn(totalSec: number): string {
+ if (totalSec < 60) return `${totalSec} 秒`
+ const m = Math.floor(totalSec / 60)
+ const s = totalSec % 60
+ if (m < 60) return `${m} 分 ${s} 秒`
+ const h = Math.floor(m / 60)
+ const mm = m % 60
+ return `${h} 小时 ${mm} 分`
+}
+
+export function formatRuntimeSeconds(sec: number | null | undefined): string {
+ if (typeof sec !== 'number' || !Number.isFinite(sec)) return '—'
+ return formatDurationCn(Math.max(0, Math.floor(sec)))
+}
+
+export function formatFfmpegSpeed(speed: number | null | undefined): string {
+ if (typeof speed !== 'number' || !Number.isFinite(speed)) return '—'
+ return `${speed.toFixed(2)}x`
+}
diff --git a/electron/src/renderer/src/main.tsx b/electron/src/renderer/src/main.tsx
index 91a72c0..9cbe964 100644
--- a/electron/src/renderer/src/main.tsx
+++ b/electron/src/renderer/src/main.tsx
@@ -3,11 +3,15 @@ import ReactDOM from 'react-dom/client'
import { initApiFromMain } from './api'
import App from './App'
import './App.css'
+import './ui-polish.css'
-void initApiFromMain().then(() => {
- ReactDOM.createRoot(document.getElementById('root')!).render(
+const root = document.getElementById('root')
+if (root) {
+ ReactDOM.createRoot(root).render(
,
)
-})
+}
+
+void initApiFromMain()
diff --git a/electron/src/renderer/src/pbAuthGate.ts b/electron/src/renderer/src/pbAuthGate.ts
new file mode 100644
index 0000000..0d80c91
--- /dev/null
+++ b/electron/src/renderer/src/pbAuthGate.ts
@@ -0,0 +1,22 @@
+export const STORAGE_PB_AUTH_GATE = 'pb_auth_gate_enabled'
+
+export const PB_AUTH_GATE_EVENT = 'pb-auth-gate-changed'
+
+/** 为 true 时:侧栏显示登录/注册,直播控制需 PocketBase 会员登录 */
+export function getPbAuthGateEnabled(): boolean {
+ try {
+ return localStorage.getItem(STORAGE_PB_AUTH_GATE) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function setPbAuthGateEnabled(enabled: boolean): void {
+ try {
+ if (enabled) localStorage.setItem(STORAGE_PB_AUTH_GATE, '1')
+ else localStorage.removeItem(STORAGE_PB_AUTH_GATE)
+ } catch {
+ /* ignore */
+ }
+ window.dispatchEvent(new CustomEvent(PB_AUTH_GATE_EVENT, { detail: enabled }))
+}
diff --git a/electron/src/renderer/src/types/live.ts b/electron/src/renderer/src/types/live.ts
new file mode 100644
index 0000000..d0b249a
--- /dev/null
+++ b/electron/src/renderer/src/types/live.ts
@@ -0,0 +1,59 @@
+export type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean }
+
+export type LiveDetailState = {
+ processStatus: string
+ recentLog: string
+ recentError: string
+ businessStatus?: string
+ businessNote?: string
+ ffmpegPids?: number[]
+ logAgeSeconds?: number | null
+ isPushing?: boolean
+ ffmpegFrame?: number | null
+ ffmpegSpeed?: number | null
+ ffmpegProgressStalled?: boolean
+ ffmpegSpeedLow?: boolean
+}
+
+export type ControlStatusPayload = {
+ process_status?: string
+ recent_log?: string
+ recent_error?: string
+ business_status?: string
+ business_note?: string
+ ffmpeg_pids?: unknown[]
+ log_age_seconds?: number
+ is_pushing?: boolean
+ ffmpeg_frame?: number
+ ffmpeg_speed?: number
+ ffmpeg_progress_stalled?: boolean
+ ffmpeg_speed_low?: boolean
+ output?: string
+ pm2_output?: string
+ message?: string
+ note?: string
+}
+
+export function liveDetailFromStatus(data: ControlStatusPayload, processStatus: string): LiveDetailState {
+ if (processStatus === 'not_found') {
+ return { processStatus, recentLog: '', recentError: '' }
+ }
+ const pids = Array.isArray(data.ffmpeg_pids)
+ ? data.ffmpeg_pids.map((x) => Number(x)).filter((x) => Number.isFinite(x))
+ : []
+ const ageRaw = data.log_age_seconds
+ return {
+ processStatus,
+ recentLog: String(data.recent_log ?? ''),
+ recentError: String(data.recent_error ?? ''),
+ businessStatus: typeof data.business_status === 'string' ? data.business_status : '',
+ businessNote: typeof data.business_note === 'string' ? data.business_note : '',
+ ffmpegPids: pids,
+ logAgeSeconds: typeof ageRaw === 'number' && Number.isFinite(ageRaw) ? ageRaw : null,
+ isPushing: data.is_pushing === true,
+ ffmpegFrame: typeof data.ffmpeg_frame === 'number' ? data.ffmpeg_frame : null,
+ ffmpegSpeed: typeof data.ffmpeg_speed === 'number' ? data.ffmpeg_speed : null,
+ ffmpegProgressStalled: data.ffmpeg_progress_stalled === true,
+ ffmpegSpeedLow: data.ffmpeg_speed_low === true,
+ }
+}
diff --git a/electron/src/renderer/src/ui-polish.css b/electron/src/renderer/src/ui-polish.css
new file mode 100644
index 0000000..68f83bc
--- /dev/null
+++ b/electron/src/renderer/src/ui-polish.css
@@ -0,0 +1,571 @@
+/* —— UI 动效与视觉增强 —— */
+
+@keyframes ui-fade-slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes ui-shimmer {
+ 0% {
+ transform: translateX(-120%) skewX(-12deg);
+ }
+ 100% {
+ transform: translateX(220%) skewX(-12deg);
+ }
+}
+
+@keyframes ui-logo-glow {
+ 0%,
+ 100% {
+ box-shadow: 0 4px 16px rgba(91, 157, 255, 0.35), 0 0 0 0 rgba(91, 157, 255, 0.2);
+ }
+ 50% {
+ box-shadow: 0 6px 22px rgba(91, 157, 255, 0.5), 0 0 0 6px rgba(91, 157, 255, 0.06);
+ }
+}
+
+@keyframes ui-pulse-core {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(0.88);
+ opacity: 0.85;
+ }
+}
+
+@keyframes ui-pulse-ring {
+ 0% {
+ transform: scale(0.65);
+ opacity: 0.75;
+ }
+ 100% {
+ transform: scale(2.2);
+ opacity: 0;
+ }
+}
+
+@keyframes ui-flow-dot {
+ 0% {
+ opacity: 0;
+ transform: translateX(0);
+ }
+ 20% {
+ opacity: 1;
+ }
+ 80% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(24px);
+ }
+}
+
+@keyframes ui-score-pop {
+ from {
+ transform: scale(0.92);
+ opacity: 0.6;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes ui-startup-orbit {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* 页面切换 */
+.app-view-enter {
+ animation: ui-fade-slide-up 0.42s var(--ease) both;
+}
+
+.app-main > .card {
+ animation: ui-fade-slide-up 0.38s var(--ease) both;
+}
+
+.app-main > .card:nth-child(2) {
+ animation-delay: 0.05s;
+}
+
+.app-main > .card:nth-child(3) {
+ animation-delay: 0.1s;
+}
+
+.app-main > .card:nth-child(4) {
+ animation-delay: 0.14s;
+}
+
+/* 侧栏导航图标 */
+.app-sidebar__link {
+ gap: 10px;
+ overflow: hidden;
+ transition:
+ background 0.2s var(--ease),
+ color 0.2s var(--ease),
+ transform 0.18s var(--ease),
+ box-shadow 0.22s var(--ease);
+}
+
+.app-sidebar__link:active {
+ transform: scale(0.98);
+}
+
+.app-sidebar__link-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ color: inherit;
+ opacity: 0.72;
+ background: rgba(255, 255, 255, 0.04);
+ transition:
+ opacity 0.2s var(--ease),
+ background 0.2s var(--ease),
+ transform 0.25s var(--ease);
+}
+
+:root[data-theme="light"] .app-sidebar__link-icon {
+ background: rgba(15, 23, 42, 0.05);
+}
+
+.app-sidebar__link:hover .app-sidebar__link-icon {
+ opacity: 0.95;
+ transform: translateY(-1px);
+}
+
+.app-sidebar__link.is-active .app-sidebar__link-icon {
+ opacity: 1;
+ color: var(--accent);
+ background: rgba(91, 157, 255, 0.16);
+}
+
+.app-sidebar__link.is-active {
+ box-shadow:
+ inset 3px 0 0 var(--accent),
+ 0 0 0 1px rgba(91, 157, 255, 0.08);
+}
+
+/* Logo 呼吸光 */
+.app-sidebar__logo {
+ position: relative;
+ overflow: hidden;
+ animation: ui-logo-glow 3.6s ease-in-out infinite;
+}
+
+.app-sidebar__logo::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.28) 50%, transparent 65%);
+ transform: translateX(-120%) skewX(-12deg);
+ animation: ui-shimmer 4.5s ease-in-out infinite;
+ pointer-events: none;
+}
+
+/* 工作区背景网格 */
+.app-workspace {
+ position: relative;
+ isolation: isolate;
+}
+
+.app-workspace::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ opacity: 0.45;
+ background-image:
+ radial-gradient(circle at 20% 10%, rgba(91, 157, 255, 0.09), transparent 42%),
+ radial-gradient(circle at 85% 25%, rgba(61, 214, 140, 0.07), transparent 38%),
+ linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
+ background-size: auto, auto, 28px 28px, 28px 28px;
+}
+
+:root[data-theme="light"] .app-workspace::before {
+ opacity: 0.55;
+ background-image:
+ radial-gradient(circle at 18% 8%, rgba(37, 99, 235, 0.1), transparent 44%),
+ radial-gradient(circle at 82% 22%, rgba(5, 150, 105, 0.08), transparent 40%),
+ linear-gradient(rgba(15, 23, 42, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(15, 23, 42, 0.04) 1px, transparent 1px);
+ background-size: auto, auto, 28px 28px, 28px 28px;
+}
+
+.app-workspace__header,
+.app-workspace__scroll {
+ position: relative;
+ z-index: 1;
+}
+
+/* 卡片悬停 */
+.card {
+ transition:
+ box-shadow 0.28s var(--ease),
+ border-color 0.25s var(--ease),
+ transform 0.28s var(--ease);
+}
+
+.card:hover {
+ border-color: rgba(91, 157, 255, 0.18);
+ box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06);
+}
+
+.card--live-command {
+ position: relative;
+}
+
+.card--live-command::before {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(120deg, rgba(61, 214, 140, 0.35), rgba(91, 157, 255, 0.2), rgba(124, 92, 255, 0.25));
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ opacity: 0.65;
+}
+
+/* Live pulse */
+.live-pulse {
+ position: relative;
+ display: inline-flex;
+ width: 10px;
+ height: 10px;
+ margin-right: 6px;
+ vertical-align: middle;
+}
+
+.live-pulse__core {
+ position: absolute;
+ inset: 2px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: ui-pulse-core 1.6s ease-in-out infinite;
+}
+
+.live-pulse__ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ animation: ui-pulse-ring 1.6s ease-out infinite;
+}
+
+.live-pulse--live {
+ color: var(--success);
+}
+
+.live-pulse--warn {
+ color: var(--warn);
+}
+
+.live-pulse--idle {
+ color: var(--text-muted);
+}
+
+.live-command__badge {
+ gap: 2px;
+ transition: transform 0.2s var(--ease), box-shadow 0.25s var(--ease);
+}
+
+.live-command__badge:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
+}
+
+/* 统计格 */
+.live-command__stat {
+ position: relative;
+ overflow: hidden;
+ transition:
+ transform 0.22s var(--ease),
+ border-color 0.22s var(--ease),
+ box-shadow 0.22s var(--ease);
+}
+
+.live-command__stat:hover {
+ transform: translateY(-2px);
+ border-color: rgba(91, 157, 255, 0.22);
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
+}
+
+.live-command__stat-emoji {
+ display: block;
+ font-size: 15px;
+ line-height: 1;
+ margin-bottom: 4px;
+ filter: saturate(1.1);
+}
+
+/* 流水线重设计 */
+.live-pipeline {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ margin-top: 4px;
+}
+
+.live-pipeline__segment {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+}
+
+.live-pipeline__segment:first-child .live-pipeline__step {
+ flex: 1;
+}
+
+.live-pipeline__flow {
+ flex-shrink: 0;
+ width: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--accent);
+}
+
+.live-pipeline__flow-line,
+.live-pipeline__flow-head {
+ stroke: currentColor;
+ stroke-opacity: 0.45;
+}
+
+.live-pipeline__flow-dot {
+ fill: var(--accent);
+}
+
+.live-pipeline__flow-dot--a {
+ animation: ui-flow-dot 2.2s ease-in-out infinite;
+}
+
+.live-pipeline__flow-dot--b {
+ animation: ui-flow-dot 2.2s ease-in-out 0.55s infinite;
+}
+
+.live-pipeline__step {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 76px;
+ flex: 1;
+ padding: 14px 16px;
+ border-radius: 16px;
+ color: #fff;
+ box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+ transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease);
+}
+
+.live-pipeline__step:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.14);
+}
+
+.live-pipeline__step-icon {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.14);
+ backdrop-filter: blur(4px);
+}
+
+.live-pipeline__step-text span {
+ display: block;
+ margin-bottom: 3px;
+ font-size: 11px;
+ font-weight: 650;
+ opacity: 0.82;
+}
+
+.live-pipeline__step-text strong {
+ display: block;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+/* 质量环 */
+.score-ring {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.score-ring__track {
+ opacity: 0.14;
+}
+
+.score-ring__arc {
+ transition: stroke-dasharray 0.6s var(--ease);
+}
+
+.score-ring__value {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ font-size: 15px;
+ font-weight: 800;
+ animation: ui-score-pop 0.5s var(--ease) both;
+}
+
+.quality-guard {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
+ backdrop-filter: blur(6px);
+ transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
+}
+
+.quality-guard:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.quality-guard__body {
+ flex: 1;
+ min-width: 0;
+}
+
+.quality-guard__head {
+ align-items: center;
+}
+
+.quality-guard__title {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 650;
+}
+
+.quality-guard__title::before {
+ content: "📡";
+ font-size: 14px;
+}
+
+/* 启动遮罩增强 */
+.app-startup-overlay__panel {
+ animation: ui-fade-slide-up 0.45s var(--ease) both;
+}
+
+.app-startup-overlay__brand {
+ width: 52px;
+ height: 52px;
+ margin: 0 auto 14px;
+ border-radius: 14px;
+ display: grid;
+ place-items: center;
+ background: linear-gradient(145deg, #3d7eff, #7c5cff);
+ color: #fff;
+ box-shadow: 0 8px 28px rgba(91, 157, 255, 0.4);
+}
+
+.app-startup-overlay__rings {
+ position: relative;
+ width: 52px;
+ height: 52px;
+ margin: 0 auto 16px;
+}
+
+.app-startup-overlay__rings::before,
+.app-startup-overlay__rings::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ border-top-color: var(--accent);
+ animation: ui-startup-orbit 1.1s linear infinite;
+}
+
+.app-startup-overlay__rings::after {
+ inset: 6px;
+ border-top-color: rgba(61, 214, 140, 0.85);
+ animation-duration: 0.85s;
+ animation-direction: reverse;
+}
+
+.app-startup-overlay__progress {
+ height: 3px;
+ margin-top: 14px;
+ border-radius: var(--radius-pill);
+ background: rgba(91, 157, 255, 0.12);
+ overflow: hidden;
+}
+
+.app-startup-overlay__progress-bar {
+ height: 100%;
+ width: 38%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--accent), var(--success));
+ animation: ui-shimmer 1.8s ease-in-out infinite;
+}
+
+/* 预检项动效 */
+.live-preflight-item {
+ transition:
+ transform 0.2s var(--ease),
+ border-color 0.22s var(--ease),
+ background 0.22s var(--ease);
+}
+
+.live-preflight-item:hover {
+ transform: translateY(-1px);
+ border-color: rgba(91, 157, 255, 0.2);
+}
+
+.live-preflight-item.is-ok .live-preflight-item__dot {
+ animation: ui-pulse-core 2s ease-in-out infinite;
+}
+
+@media (max-width: 860px) {
+ .live-pipeline {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .live-pipeline__segment {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .live-pipeline__flow {
+ transform: rotate(90deg);
+ height: 32px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts
index d3711ac..aa1ea3a 100644
--- a/electron/src/renderer/src/vite-env.d.ts
+++ b/electron/src/renderer/src/vite-env.d.ts
@@ -18,19 +18,6 @@ interface Window {
getApiBase?: () => Promise
getApiToken?: () => Promise
}
- remoteBinding?: {
- getStatus: () => Promise<{
- bindHost: string
- remoteEnabled: boolean
- port: number
- hasToken: boolean
- candidates: { address: string; name: string }[]
- envHostOverride: boolean
- }>
- setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
- generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
- getBindPayload: (selectedIp: string) => Promise | null>
- }
workspace?: {
logLiveEvent: (payload: unknown) => Promise
logUserAction: (payload: unknown) => Promise
@@ -96,4 +83,5 @@ interface Window {
openLogsFolder?: () => Promise
openSystemSettings?: (page: string) => Promise<{ ok: true } | { ok: false; error: string }>
}
+ youlive?: import('../../preload/youliveApi').YouliveApi
}
diff --git a/electron/src/renderer/src/youtubeLiveUtils.ts b/electron/src/renderer/src/youtubeLiveUtils.ts
new file mode 100644
index 0000000..721677c
--- /dev/null
+++ b/electron/src/renderer/src/youtubeLiveUtils.ts
@@ -0,0 +1,169 @@
+import { apiFetchJson, apiUrl } from './api'
+import type { GpuCaps } from './types/live'
+
+export type YoutubeIniModel = {
+ keys: string[]
+ activeIndex: number
+ options: Record
+ header: string
+}
+
+export type ProChannelEditor = {
+ keys: string[]
+ activeIndex: number
+ urlText: string
+ /** YouTube 频道/直播页地址,供目标端监听(弹幕、观众数) */
+ youtubeWatchText?: string
+}
+
+const YT_WATCH_LS_PREFIX = 'relay-yt-watch:'
+
+function readLegacyYoutubeWatchLocal(relayChannelId: string): string {
+ try {
+ return localStorage.getItem(`${YT_WATCH_LS_PREFIX}${relayChannelId}`) ?? ''
+ } catch {
+ return ''
+ }
+}
+
+function clearLegacyYoutubeWatchLocal(relayChannelId: string): void {
+ try {
+ localStorage.removeItem(`${YT_WATCH_LS_PREFIX}${relayChannelId}`)
+ } catch {
+ /* ignore */
+ }
+}
+
+/** @deprecated 仅用于迁移;新代码请用 loadYoutubeWatchUrl */
+export function readYoutubeWatchInput(relayChannelId: string): string {
+ return readLegacyYoutubeWatchLocal(relayChannelId)
+}
+
+/** @deprecated 仅用于迁移;新代码请用 saveYoutubeWatchUrl */
+export function writeYoutubeWatchInput(relayChannelId: string, value: string): void {
+ try {
+ const v = value.trim()
+ if (v) localStorage.setItem(`${YT_WATCH_LS_PREFIX}${relayChannelId}`, v)
+ else localStorage.removeItem(`${YT_WATCH_LS_PREFIX}${relayChannelId}`)
+ } catch {
+ /* ignore */
+ }
+}
+
+export async function loadYoutubeWatchUrl(channelId: string): Promise {
+ const cid = (channelId || 'single').trim() || 'single'
+ try {
+ const data = await apiFetchJson<{ url?: string }>(
+ apiUrl(`/relay_pro/youtube_watch?channel_id=${encodeURIComponent(cid)}`),
+ )
+ const fromApi = (data.url ?? '').trim()
+ if (fromApi) return fromApi
+ const legacy = readLegacyYoutubeWatchLocal(cid).trim()
+ if (legacy) {
+ await saveYoutubeWatchUrl(cid, legacy)
+ clearLegacyYoutubeWatchLocal(cid)
+ return legacy
+ }
+ return ''
+ } catch {
+ return readLegacyYoutubeWatchLocal(cid).trim()
+ }
+}
+
+export async function saveYoutubeWatchUrl(channelId: string, value: string): Promise {
+ const cid = (channelId || 'single').trim() || 'single'
+ const data = await apiFetchJson<{ message?: string }>(apiUrl('/relay_pro/youtube_watch'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ channelId: cid, url: value.trim() }),
+ })
+ clearLegacyYoutubeWatchLocal(cid)
+ return data.message ?? '已保存'
+}
+
+export function youtubeIniActiveKey(m: YoutubeIniModel): string {
+ if (!m.keys?.length) return ''
+ const i = Math.max(0, Math.min(m.activeIndex, m.keys.length - 1))
+ return (m.keys[i] || '').trim()
+}
+
+export function proChannelPm2Process(channelId: string): string {
+ return `youtube2__${channelId}`
+}
+
+/** 与 web2_relay_pro._norm_stream_key 一致,用于保存前去重校验 */
+export function normStreamKeyForDedup(k: string): string {
+ let s = (k || '').trim()
+ if (!s) return ''
+ const low = s.toLowerCase()
+ if (low.startsWith('rtmp://') || low.startsWith('rtmps://')) {
+ const parts = s.replace(/\/+$/, '').split('/')
+ s = parts[parts.length - 1] || s
+ }
+ return s.trim().toLowerCase()
+}
+
+export function validateStreamKeysUnique(keys: string[]): string | null {
+ const seen = new Set()
+ for (const k of keys) {
+ const t = k.trim()
+ if (!t) continue
+ const n = normStreamKeyForDedup(t)
+ if (!n) continue
+ if (seen.has(n)) return '同一线路内不能重复同一串流密钥'
+ seen.add(n)
+ }
+ return null
+}
+
+/** 仅含空行与 # 注释时视为空,避免默认模板占满输入框 */
+export function douyinUrlListDisplayFromServer(content: string): string {
+ const lines = String(content ?? '').split(/\r?\n/)
+ const hasReal = lines.some((line) => {
+ const t = line.trim()
+ if (!t) return false
+ if (t.startsWith('#')) return false
+ return true
+ })
+ return hasReal ? String(content) : ''
+}
+
+export function countConfigUrlLines(content: string): number {
+ return String(content ?? '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#')).length
+}
+
+export function mergeYoutubeOptionsForUi(opts: Record, caps: GpuCaps | null): Record {
+ const o = { ...opts }
+ const nv = caps?.nvenc_available ?? false
+ const capsKnown = caps !== null
+ const res = (o.output_resolution || '').toLowerCase()
+ o.output_resolution = res.includes('1080') ? '1080p' : '720p'
+
+ const ug = (o.use_gpu || '').trim()
+ if (!ug) {
+ o.use_gpu = capsKnown ? (nv ? '是' : '无') : '是'
+ } else if (['是', '否', '无'].includes(ug)) {
+ if (capsKnown && ug === '是' && !nv) o.use_gpu = '无'
+ } else {
+ o.use_gpu = capsKnown ? (nv ? '否' : '无') : '否'
+ }
+
+ const ad = (o.audio_denoise || '').trim().toLowerCase()
+ if (!ad) o.audio_denoise = '否'
+ else if (['是', '开', '1', 'true', 'on', 'yes'].includes(ad)) o.audio_denoise = '是'
+ else o.audio_denoise = '否'
+
+ return o
+}
+
+export function formatControlOutput(data: Record): string {
+ if (typeof data.output === 'string') return data.output
+ return (
+ [data.pm2_output, data.message, data.note]
+ .filter((x): x is string => typeof x === 'string')
+ .join('\n') || '已完成'
+ )
+}
diff --git a/electron/src/youlive/main/db/index.ts b/electron/src/youlive/main/db/index.ts
new file mode 100644
index 0000000..1e3a00c
--- /dev/null
+++ b/electron/src/youlive/main/db/index.ts
@@ -0,0 +1,228 @@
+import { app } from 'electron'
+import initSqlJs, { type Database } from 'sql.js'
+import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
+import { join } from 'path'
+import * as schema from './schema'
+import { DEFAULT_SETTINGS } from '../../shared/types'
+import type { AppSettings } from '../../shared/types'
+
+let db: SQLJsDatabase | null = null
+let sqlite: Database | null = null
+let saveTimer: ReturnType | null = null
+
+export function getDbPath(): string {
+ const dir = app.getPath('userData')
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
+ return join(dir, 'youlive.db')
+}
+
+function getWasmDir(): string {
+ if (app.isPackaged) {
+ return join(process.resourcesPath, 'sql.js', 'dist')
+ }
+ return join(app.getAppPath(), 'node_modules', 'sql.js', 'dist')
+}
+
+export function schedulePersist(): void {
+ if (saveTimer) clearTimeout(saveTimer)
+ saveTimer = setTimeout(persistDatabase, 400)
+}
+
+export function persistDatabase(): void {
+ if (!sqlite) return
+ writeFileSync(getDbPath(), Buffer.from(sqlite.export()))
+}
+
+export async function initDatabase(): Promise> {
+ if (db) return db
+
+ const SQL = await initSqlJs({ locateFile: (f) => join(getWasmDir(), f) })
+ const dbPath = getDbPath()
+
+ sqlite = existsSync(dbPath)
+ ? new SQL.Database(readFileSync(dbPath))
+ : new SQL.Database()
+
+ db = drizzle(sqlite, { schema })
+ runMigrations()
+ seedDefaultSettings()
+ migrateFromLegacyJson()
+ persistDatabase()
+
+ return db
+}
+
+export function getDatabase(): SQLJsDatabase {
+ if (!db) throw new Error('Database not initialized')
+ return db
+}
+
+function run(sql: string, params: unknown[] = []): void {
+ sqlite!.run(sql, params as (string | number)[])
+ schedulePersist()
+}
+
+function runMigrations(): void {
+ run(`CREATE TABLE IF NOT EXISTS watches (
+ watch_id TEXT PRIMARY KEY, kind TEXT NOT NULL, youtube_id TEXT NOT NULL,
+ label TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
+ poll_interval_sec INTEGER NOT NULL DEFAULT 30,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`)
+ run(`CREATE UNIQUE INDEX IF NOT EXISTS watches_kind_youtube_uidx ON watches(kind, youtube_id)`)
+ run(`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
+ run(`CREATE TABLE IF NOT EXISTS logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, message TEXT NOT NULL,
+ watch_id TEXT, video_id TEXT, timestamp TEXT NOT NULL)`)
+ run(`CREATE TABLE IF NOT EXISTS chat_messages (
+ id TEXT PRIMARY KEY, video_id TEXT NOT NULL, author TEXT NOT NULL,
+ author_channel_id TEXT, text TEXT NOT NULL, timestamp TEXT NOT NULL,
+ is_owner INTEGER DEFAULT 0, is_moderator INTEGER DEFAULT 0, is_member INTEGER DEFAULT 0)`)
+ run(`CREATE TABLE IF NOT EXISTS live_sessions (
+ video_id TEXT PRIMARY KEY, watch_id TEXT NOT NULL, channel_id TEXT,
+ channel_name TEXT, title TEXT NOT NULL, status TEXT NOT NULL,
+ viewer_count INTEGER, thumbnail_url TEXT, scheduled_start TEXT,
+ actual_start TEXT, last_checked_at TEXT NOT NULL, error_message TEXT)`)
+
+ try {
+ run(`ALTER TABLE watches ADD COLUMN account_id TEXT`)
+ } catch {
+ // column exists
+ }
+
+ run(`CREATE TABLE IF NOT EXISTS google_accounts (
+ account_id TEXT PRIMARY KEY, label TEXT NOT NULL, display_name TEXT, email TEXT,
+ cookie_data TEXT NOT NULL, partition_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
+ is_default INTEGER NOT NULL DEFAULT 0, channel_count INTEGER NOT NULL DEFAULT 0,
+ last_sync_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`)
+
+ run(`CREATE TABLE IF NOT EXISTS managed_channels (
+ channel_id TEXT NOT NULL, account_id TEXT NOT NULL, title TEXT NOT NULL,
+ handle TEXT, thumbnail_url TEXT, is_active_on_account INTEGER DEFAULT 0,
+ is_watching INTEGER DEFAULT 0, synced_at TEXT NOT NULL,
+ PRIMARY KEY (channel_id, account_id))`)
+ run(`CREATE INDEX IF NOT EXISTS managed_account_idx ON managed_channels(account_id)`)
+ run(`CREATE INDEX IF NOT EXISTS managed_channel_idx ON managed_channels(channel_id)`)
+ run(`CREATE INDEX IF NOT EXISTS watches_account_idx ON watches(account_id)`)
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN peak_viewer_count INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE chat_messages ADD COLUMN kind TEXT DEFAULT 'text'`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE chat_messages ADD COLUMN amount TEXT`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE watches ADD COLUMN status_debounce_polls INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN is_members_only INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN is_private INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN is_unlisted INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN is_age_restricted INTEGER`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN restriction_note TEXT`)
+ } catch {
+ // column exists
+ }
+
+ try {
+ run(`ALTER TABLE live_sessions ADD COLUMN screenshot_at TEXT`)
+ } catch {
+ // column exists
+ }
+
+ run(`CREATE TABLE IF NOT EXISTS chat_alerts (
+ id TEXT PRIMARY KEY, video_id TEXT NOT NULL, watch_id TEXT NOT NULL,
+ author TEXT NOT NULL, text TEXT NOT NULL, keyword TEXT NOT NULL,
+ match_mode TEXT NOT NULL, timestamp TEXT NOT NULL)`)
+ run(`CREATE INDEX IF NOT EXISTS chat_alerts_video_idx ON chat_alerts(video_id, timestamp)`)
+}
+
+function seedDefaultSettings(): void {
+ const result = sqlite!.exec('SELECT COUNT(*) AS c FROM settings')
+ const count = (result[0]?.values[0]?.[0] as number) ?? 0
+ if (count > 0) return
+
+ for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
+ run('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [key, JSON.stringify(value)])
+ }
+}
+
+function migrateFromLegacyJson(): void {
+ const jsonPath = join(app.getPath('userData'), 'youlive-config.json')
+ if (!existsSync(jsonPath)) return
+
+ try {
+ const data = JSON.parse(readFileSync(jsonPath, 'utf-8')) as {
+ watches?: Array>
+ settings?: Partial
+ }
+
+ const count = (sqlite!.exec('SELECT COUNT(*) FROM watches')[0]?.values[0]?.[0] as number) ?? 0
+ if (count === 0 && data.watches?.length) {
+ for (const w of data.watches) {
+ run(
+ `INSERT OR IGNORE INTO watches
+ (watch_id, kind, youtube_id, label, enabled, poll_interval_sec, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ w.watchId, w.kind, w.youtubeId, w.label,
+ w.enabled ? 1 : 0, w.pollIntervalSec, w.createdAt, w.updatedAt
+ ]
+ )
+ }
+ }
+ if (data.settings) {
+ for (const [key, value] of Object.entries(data.settings)) {
+ run('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, JSON.stringify(value)])
+ }
+ }
+ } catch {
+ // ignore
+ }
+}
+
+export function closeDatabase(): void {
+ persistDatabase()
+ sqlite?.close()
+ sqlite = null
+ db = null
+}
+
+export function afterWrite(): void {
+ schedulePersist()
+}
diff --git a/electron/src/youlive/main/db/schema.ts b/electron/src/youlive/main/db/schema.ts
new file mode 100644
index 0000000..001c542
--- /dev/null
+++ b/electron/src/youlive/main/db/schema.ts
@@ -0,0 +1,129 @@
+import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core'
+
+export const watches = sqliteTable(
+ 'watches',
+ {
+ watchId: text('watch_id').primaryKey(),
+ kind: text('kind', { enum: ['video', 'channel'] }).notNull(),
+ youtubeId: text('youtube_id').notNull(),
+ label: text('label').notNull(),
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+ pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
+ accountId: text('account_id'),
+ statusDebouncePolls: integer('status_debounce_polls'),
+ createdAt: text('created_at').notNull(),
+ updatedAt: text('updated_at').notNull()
+ },
+ (t) => [index('watches_youtube_idx').on(t.kind, t.youtubeId), index('watches_account_idx').on(t.accountId)]
+)
+
+export const settings = sqliteTable('settings', {
+ key: text('key').primaryKey(),
+ value: text('value').notNull()
+})
+
+export const logs = sqliteTable(
+ 'logs',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ level: text('level', { enum: ['info', 'warn', 'error'] }).notNull(),
+ message: text('message').notNull(),
+ watchId: text('watch_id'),
+ videoId: text('video_id'),
+ timestamp: text('timestamp').notNull()
+ },
+ (t) => [index('logs_timestamp_idx').on(t.timestamp)]
+)
+
+export const chatMessages = sqliteTable(
+ 'chat_messages',
+ {
+ id: text('id').primaryKey(),
+ videoId: text('video_id').notNull(),
+ author: text('author').notNull(),
+ authorChannelId: text('author_channel_id'),
+ text: text('text').notNull(),
+ timestamp: text('timestamp').notNull(),
+ isOwner: integer('is_owner', { mode: 'boolean' }).default(false),
+ isModerator: integer('is_moderator', { mode: 'boolean' }).default(false),
+ isMember: integer('is_member', { mode: 'boolean' }).default(false),
+ kind: text('kind').default('text'),
+ amount: text('amount')
+ },
+ (t) => [index('chat_video_idx').on(t.videoId, t.timestamp)]
+)
+
+export const chatAlerts = sqliteTable(
+ 'chat_alerts',
+ {
+ id: text('id').primaryKey(),
+ videoId: text('video_id').notNull(),
+ watchId: text('watch_id').notNull(),
+ author: text('author').notNull(),
+ text: text('text').notNull(),
+ keyword: text('keyword').notNull(),
+ matchMode: text('match_mode').notNull(),
+ timestamp: text('timestamp').notNull()
+ },
+ (t) => [index('chat_alerts_video_idx').on(t.videoId, t.timestamp)]
+)
+
+export const liveSessions = sqliteTable(
+ 'live_sessions',
+ {
+ videoId: text('video_id').primaryKey(),
+ watchId: text('watch_id').notNull(),
+ channelId: text('channel_id'),
+ channelName: text('channel_name'),
+ title: text('title').notNull(),
+ status: text('status').notNull(),
+ viewerCount: integer('viewer_count'),
+ thumbnailUrl: text('thumbnail_url'),
+ scheduledStart: text('scheduled_start'),
+ actualStart: text('actual_start'),
+ lastCheckedAt: text('last_checked_at').notNull(),
+ errorMessage: text('error_message'),
+ peakViewerCount: integer('peak_viewer_count'),
+ isMembersOnly: integer('is_members_only', { mode: 'boolean' }),
+ isPrivate: integer('is_private', { mode: 'boolean' }),
+ isUnlisted: integer('is_unlisted', { mode: 'boolean' }),
+ isAgeRestricted: integer('is_age_restricted', { mode: 'boolean' }),
+ restrictionNote: text('restriction_note'),
+ screenshotAt: text('screenshot_at')
+ },
+ (t) => [index('sessions_watch_idx').on(t.watchId)]
+)
+
+export const googleAccounts = sqliteTable('google_accounts', {
+ accountId: text('account_id').primaryKey(),
+ label: text('label').notNull(),
+ displayName: text('display_name'),
+ email: text('email'),
+ cookieData: text('cookie_data').notNull(),
+ partitionId: text('partition_id').notNull(),
+ status: text('status').notNull().default('active'),
+ isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
+ channelCount: integer('channel_count').notNull().default(0),
+ lastSyncAt: text('last_sync_at'),
+ createdAt: text('created_at').notNull(),
+ updatedAt: text('updated_at').notNull()
+})
+
+export const managedChannels = sqliteTable(
+ 'managed_channels',
+ {
+ channelId: text('channel_id').notNull(),
+ accountId: text('account_id').notNull(),
+ title: text('title').notNull(),
+ handle: text('handle'),
+ thumbnailUrl: text('thumbnail_url'),
+ isActiveOnAccount: integer('is_active_on_account', { mode: 'boolean' }).default(false),
+ isWatching: integer('is_watching', { mode: 'boolean' }).default(false),
+ syncedAt: text('synced_at').notNull()
+ },
+ (t) => [
+ primaryKey({ columns: [t.channelId, t.accountId] }),
+ index('managed_account_idx').on(t.accountId),
+ index('managed_channel_idx').on(t.channelId)
+ ]
+)
diff --git a/electron/src/youlive/main/index.ts b/electron/src/youlive/main/index.ts
new file mode 100644
index 0000000..9a78ed1
--- /dev/null
+++ b/electron/src/youlive/main/index.ts
@@ -0,0 +1,134 @@
+import { app, BrowserWindow, shell, nativeImage } from 'electron'
+import { join } from 'path'
+import { existsSync } from 'fs'
+import { initDatabase, closeDatabase } from './db'
+import { MonitorManager } from './services/monitor-manager'
+import { RecordManager } from './services/record-manager'
+import { RelayManager } from './services/relay-manager'
+import { TrayManager } from './services/tray-manager'
+import { registerIpcHandlers } from './ipc/handlers'
+import { getSettings } from './services/store'
+import { AccountManager } from './services/account-manager'
+import { applyLaunchAtLogin } from './services/stats-service'
+import { disposePageScraper } from './services/youtube/page-scraper'
+import { partnerService } from './services/partner-service'
+import { preloadDeviceOverview } from './services/device-detector/cache'
+import type { MonitorEvent } from '../shared/types'
+
+let mainWindow: BrowserWindow | null = null
+let isQuitting = false
+
+const recorder = new RecordManager()
+const relay = new RelayManager()
+const trayManager = new TrayManager()
+
+const monitor = new MonitorManager((_event: MonitorEvent) => {})
+const accounts = new AccountManager()
+monitor.setRecorder(recorder)
+monitor.setRelay(relay)
+
+function getWindow(): BrowserWindow | null {
+ return mainWindow
+}
+
+function updateTray(): void {
+ trayManager.updateMenu(getWindow, monitor, recorder)
+}
+
+monitor.setTrayUpdater(updateTray)
+
+function resolveAppIcon(): Electron.NativeImage | undefined {
+ const candidates = app.isPackaged
+ ? [join(process.resourcesPath, 'icon.png')]
+ : [
+ join(process.cwd(), 'build', 'icon.png'),
+ join(process.cwd(), 'build', 'icon.ico')
+ ]
+ for (const p of candidates) {
+ if (existsSync(p)) return nativeImage.createFromPath(p)
+ }
+ return undefined
+}
+
+function createWindow(): void {
+ const icon = resolveAppIcon()
+ mainWindow = new BrowserWindow({
+ width: 1320,
+ height: 860,
+ minWidth: 960,
+ minHeight: 600,
+ show: false,
+ title: 'youLIVE — YouTube 直播监听',
+ backgroundColor: '#07070a',
+ ...(icon ? { icon } : {}),
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ sandbox: false,
+ contextIsolation: true,
+ nodeIntegration: false
+ }
+ })
+
+ mainWindow.on('ready-to-show', () => {
+ mainWindow?.show()
+ })
+
+ mainWindow.on('close', (e) => {
+ if (!isQuitting && getSettings().minimizeToTray) {
+ e.preventDefault()
+ mainWindow?.hide()
+ }
+ })
+
+ mainWindow.webContents.setWindowOpenHandler((details) => {
+ shell.openExternal(details.url)
+ return { action: 'deny' }
+ })
+
+ monitor.setMainWindow(mainWindow)
+ accounts.setMainWindow(mainWindow)
+
+ if (process.env.ELECTRON_RENDERER_URL) {
+ mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
+ } else {
+ mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
+ }
+}
+
+app.whenReady().then(async () => {
+ if (process.platform === 'win32') {
+ app.setAppUserModelId('com.youlive.app')
+ }
+
+ await initDatabase()
+ partnerService.init()
+ applyLaunchAtLogin(getSettings().launchAtLogin)
+ registerIpcHandlers(monitor, recorder, relay, accounts)
+ preloadDeviceOverview()
+ createWindow()
+ trayManager.init(getWindow, monitor, recorder)
+
+ if (getSettings().autoStartMonitor) {
+ await monitor.start()
+ }
+
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow()
+ else mainWindow?.show()
+ })
+})
+
+app.on('before-quit', () => {
+ isQuitting = true
+ disposePageScraper()
+})
+
+app.on('window-all-closed', () => {
+ if (getSettings().minimizeToTray && !isQuitting) return
+ monitor.stop()
+ recorder.stopAll()
+ relay.stopAll()
+ closeDatabase()
+ trayManager.destroy()
+ if (process.platform !== 'darwin') app.quit()
+})
diff --git a/electron/src/youlive/main/ipc/handlers.ts b/electron/src/youlive/main/ipc/handlers.ts
new file mode 100644
index 0000000..d636536
--- /dev/null
+++ b/electron/src/youlive/main/ipc/handlers.ts
@@ -0,0 +1,202 @@
+import { ipcMain, shell } from 'electron'
+import { IPC } from '../../shared/types'
+import type { ExportFormat } from '../../shared/types'
+import type { MonitorManager } from '../services/monitor-manager'
+import type { RecordManager } from '../services/record-manager'
+import type { RelayManager } from '../services/relay-manager'
+import type { AccountManager } from '../services/account-manager'
+import { exportChat, exportLogs } from '../services/export-service'
+import { selectDirectory } from '../services/tray-manager'
+import { getAppStats } from '../services/stats-service'
+import { getStreamInfo, refreshStreamInfo } from '../services/stream-resolver'
+import { testWebhook } from '../services/webhook-service'
+import { enrichmentService } from '../services/enrichment-service'
+import { getWatches } from '../services/store'
+import { partnerService } from '../services/partner-service'
+import { getCachedDeviceOverview, preloadDeviceOverview } from '../services/device-detector/cache'
+import { getScreenshotData } from '../services/live-screenshot-service'
+import type { OfferTrigger } from '../../shared/partner-types'
+
+export function registerIpcHandlers(
+ monitor: MonitorManager,
+ recorder: RecordManager,
+ relay: RelayManager,
+ accounts: AccountManager
+): void {
+ ipcMain.handle(IPC.WATCH_LIST, () => monitor.listWatches())
+ ipcMain.handle(IPC.WATCH_PARSE, (_e, input: string) => monitor.parseInput(input))
+ ipcMain.handle(IPC.WATCH_ADD, async (_e, input: string, label?: string) =>
+ monitor.addWatchEntry(input, label)
+ )
+ ipcMain.handle(IPC.WATCH_REMOVE, (_e, watchId: string) => monitor.removeWatchEntry(watchId))
+ ipcMain.handle(IPC.WATCH_UPDATE, (_e, watchId: string, patch: object) =>
+ monitor.updateWatchEntry(watchId, patch as Parameters[1])
+ )
+ ipcMain.handle(IPC.WATCH_CHECK, async (_e, watchId: string) => {
+ await monitor.checkWatchNow(watchId)
+ return true
+ })
+
+ ipcMain.handle(IPC.MONITOR_START, async () => {
+ await monitor.start()
+ return { running: true }
+ })
+ ipcMain.handle(IPC.MONITOR_STOP, () => {
+ monitor.stop()
+ return { running: false }
+ })
+ ipcMain.handle(IPC.MONITOR_STATUS, () => ({ running: monitor.isRunning() }))
+
+ ipcMain.handle(IPC.SESSIONS_GET, () => monitor.getSessions())
+ ipcMain.handle(IPC.SESSION_HISTORY, (_e, limit?: number) => monitor.getSessionHistory(limit))
+
+ ipcMain.handle(IPC.CHAT_HISTORY, (_e, videoId: string) => monitor.getChatHistory(videoId))
+ ipcMain.handle(IPC.CHAT_SUBSCRIBE, async (_e, videoId: string) => {
+ await monitor.subscribeChatManual(videoId)
+ return true
+ })
+ ipcMain.handle(IPC.CHAT_UNSUBSCRIBE, (_e, videoId: string) => {
+ monitor.unsubscribeChat(videoId)
+ return true
+ })
+ ipcMain.handle(IPC.CHAT_ALERTS_GET, (_e, videoId?: string, limit?: number) =>
+ monitor.getChatAlertHistory(videoId, limit)
+ )
+ ipcMain.handle(IPC.CHAT_ALERTS_CLEAR, (_e, videoId?: string) =>
+ monitor.clearChatAlertHistory(videoId)
+ )
+ ipcMain.handle(IPC.SCREENSHOT_GET, (_e, videoId: string) => {
+ const data = getScreenshotData(videoId)
+ return data ? { videoId, ...data } : null
+ })
+ ipcMain.handle(IPC.SCREENSHOT_CAPTURE, async (_e, videoId: string, accountId?: string) => {
+ const shot = await monitor.captureScreenshot(videoId, accountId)
+ if (shot?.error) return { videoId, error: shot.error }
+ const data = getScreenshotData(videoId)
+ return data ? { videoId, ...data } : { videoId, error: '截图失败' }
+ })
+
+ ipcMain.handle(IPC.SETTINGS_GET, () => monitor.getAppSettings())
+ ipcMain.handle(IPC.SETTINGS_SET, (_e, patch: object) => monitor.setAppSettings(patch as object))
+ ipcMain.handle(IPC.LOGS_GET, () => monitor.getAppLogs())
+
+ ipcMain.handle(IPC.RECORD_START, async (_e, videoId: string) => {
+ const session = monitor.getSessions().find((s) => s.videoId === videoId)
+ if (!session) throw new Error('无活跃会话')
+ return recorder.start(session)
+ })
+ ipcMain.handle(IPC.RECORD_STOP, (_e, videoId: string) => recorder.stop(videoId))
+ ipcMain.handle(IPC.RECORD_STATUS, (_e, videoId?: string) => recorder.getStatus(videoId))
+ ipcMain.handle('record:streamlink-available', () => recorder.isStreamlinkAvailable())
+
+ ipcMain.handle(IPC.RELAY_START, async (_e, videoId: string, rtmpUrl?: string) => {
+ const session = monitor.getSessions().find((s) => s.videoId === videoId)
+ if (!session) throw new Error('无活跃会话')
+ return relay.start(session, rtmpUrl)
+ })
+ ipcMain.handle(IPC.RELAY_STOP, (_e, videoId: string) => relay.stop(videoId))
+ ipcMain.handle(IPC.RELAY_STATUS, (_e, videoId?: string) => relay.getStatus(videoId))
+ ipcMain.handle('relay:ffmpeg-available', () => relay.isFfmpegAvailable())
+
+ ipcMain.handle(IPC.STATS_GET, () => getAppStats(monitor, recorder, relay))
+ ipcMain.handle(IPC.STREAM_INFO, (_e, videoId: string, accountId?: string) => {
+ const session = monitor.getSessions().find((s) => s.videoId === videoId)
+ const watch = session
+ ? getWatches().find((w) => w.watchId === session.watchId)
+ : undefined
+ return getStreamInfo(videoId, accountId ?? watch?.accountId)
+ })
+ ipcMain.handle(IPC.ENRICH_GET, (_e, videoId: string, accountId?: string) =>
+ enrichmentService.enrich(videoId, { accountId })
+ )
+ ipcMain.handle(IPC.ENRICH_REFRESH, async (_e, videoId: string, accountId?: string) => {
+ enrichmentService.clearCache(videoId)
+ return enrichmentService.enrich(videoId, {
+ accountId,
+ forceRefresh: true,
+ forceBrowser: true,
+ quick: false
+ })
+ })
+ ipcMain.handle('stream:refresh', (_e, videoId: string, accountId?: string) => {
+ const session = monitor.getSessions().find((s) => s.videoId === videoId)
+ const watch = session
+ ? getWatches().find((w) => w.watchId === session.watchId)
+ : undefined
+ return refreshStreamInfo(videoId, accountId ?? watch?.accountId)
+ })
+
+ ipcMain.handle(IPC.PARTNER_CATALOG, () => partnerService.getCatalog())
+ ipcMain.handle(IPC.PARTNER_OFFERS, (_e, triggers: OfferTrigger[], limit?: number) =>
+ partnerService.getOffers({ triggers, limit })
+ )
+ ipcMain.handle(IPC.PARTNER_DISMISS, (_e, offerId: string) => {
+ partnerService.dismissOffer(offerId)
+ return true
+ })
+ ipcMain.handle(IPC.PARTNER_TRACK, (_e, offerId: string) => {
+ partnerService.trackClick(offerId)
+ return true
+ })
+ ipcMain.handle(IPC.PARTNER_VPS_SCRIPT, () => partnerService.generateVpsDeployScript())
+ ipcMain.handle(IPC.PARTNER_REFRESH, () => partnerService.refreshCatalog())
+ ipcMain.handle(IPC.PARTNER_OPEN_TUTORIAL, (_e, tutorialId: string) => {
+ const tutorial = partnerService.getCatalog().tutorials.find((t) => t.id === tutorialId)
+ if (!tutorial) return { ok: false, url: '' }
+ const local = partnerService.resolveTutorialPath(tutorialId)
+ if (local) {
+ void shell.openPath(local)
+ return { ok: true, url: local }
+ }
+ if (tutorial.url.startsWith('http')) {
+ void shell.openExternal(tutorial.url)
+ return { ok: true, url: tutorial.url }
+ }
+ return { ok: false, url: '' }
+ })
+ ipcMain.handle(IPC.PARTNER_VERSION, () => partnerService.getAppVersion())
+ ipcMain.handle('partner:toolbox', () => partnerService.getToolboxOffers())
+
+ ipcMain.handle(IPC.WEBHOOK_TEST, () => testWebhook())
+
+ ipcMain.handle(IPC.EXPORT_CHAT, (_e, videoId: string, format: ExportFormat) =>
+ exportChat(videoId, format)
+ )
+ ipcMain.handle(IPC.EXPORT_LOGS, (_e, format: ExportFormat) => exportLogs(format))
+
+ ipcMain.handle(IPC.DIALOG_SELECT_DIR, () => selectDirectory())
+ ipcMain.handle('shell:open', (_e, url: string) => shell.openExternal(url))
+
+ ipcMain.handle(IPC.ACCOUNT_LIST, () => accounts.list())
+ ipcMain.handle(IPC.ACCOUNT_ACTIVE, () => accounts.getActiveId())
+ ipcMain.handle(IPC.ACCOUNT_LOGIN, async (_e, label?: string) => accounts.login(label))
+ ipcMain.handle(IPC.ACCOUNT_REMOVE, (_e, accountId: string) => accounts.remove(accountId))
+ ipcMain.handle(IPC.ACCOUNT_SET_DEFAULT, (_e, accountId: string) => accounts.setDefault(accountId))
+ ipcMain.handle(IPC.ACCOUNT_SYNC, async (_e, accountId: string) => accounts.sync(accountId))
+ ipcMain.handle(IPC.ACCOUNT_CHANNELS, (_e, accountId: string) => accounts.channels(accountId))
+ ipcMain.handle(IPC.ACCOUNT_WATCH_ALL, (_e, accountId: string) => accounts.watchAll(accountId))
+ ipcMain.handle(IPC.ACCOUNT_WATCH_CHANNEL, (_e, accountId: string, channelId: string) =>
+ accounts.watchChannel(accountId, channelId)
+ )
+ ipcMain.handle(IPC.ACCOUNT_RELOGIN, async (_e, accountId: string) => accounts.relogin(accountId))
+ ipcMain.handle(IPC.ACCOUNT_CHECK, async (_e, accountId: string) => accounts.checkHealth(accountId))
+ ipcMain.handle('account:set-active', (_e, accountId: string | null) => {
+ accounts.setActive(accountId)
+ return accounts.getActiveId()
+ })
+
+ ipcMain.handle(IPC.DEVICE_GET, () => getCachedDeviceOverview())
+
+ ipcMain.handle(
+ 'youlive:sync-relay-watches',
+ async (
+ _e,
+ items: Array<{ relayId: string; label: string; input: string }>,
+ ) => monitor.syncRelayWatches(items),
+ )
+
+ ipcMain.handle(IPC.NOTIFY_DESKTOP, (_e, title: string, body: string) => {
+ monitor.notifyDesktop(String(title ?? ''), String(body ?? ''))
+ return true
+ })
+}
diff --git a/electron/src/youlive/main/services/account-manager.ts b/electron/src/youlive/main/services/account-manager.ts
new file mode 100644
index 0000000..ef02d28
--- /dev/null
+++ b/electron/src/youlive/main/services/account-manager.ts
@@ -0,0 +1,93 @@
+import type { BrowserWindow } from 'electron'
+import type { GoogleAccount, AccountSyncResult } from '../../shared/account-types'
+import type { WatchEntry } from '../../shared/types'
+import { openGoogleLoginWindow, openGoogleReloginWindow } from './youtube/account-login'
+import {
+ listGoogleAccounts,
+ listManagedChannels,
+ removeGoogleAccount,
+ setDefaultAccount,
+ syncAccountChannels,
+ addWatchFromChannel,
+ watchAllChannels,
+ getActiveAccountId,
+ linkOrphanWatchesToAccount,
+ getFallbackActiveAccountId
+} from './account-store'
+import { checkAccountHealth } from './youtube/account-health'
+import { getSettings, setSettings } from './store'
+import { invalidateAllClients } from './youtube/client-pool'
+
+export class AccountManager {
+ private mainWindow: BrowserWindow | null = null
+
+ setMainWindow(win: BrowserWindow | null): void {
+ this.mainWindow = win
+ }
+
+ list(): GoogleAccount[] {
+ return listGoogleAccounts()
+ }
+
+ getActiveId(): string | null {
+ return getActiveAccountId()
+ }
+
+ setActive(accountId: string | null): void {
+ setSettings({ activeAccountId: accountId })
+ if (accountId) setDefaultAccount(accountId)
+ }
+
+ async login(label?: string): Promise {
+ const account = await openGoogleLoginWindow(label, this.mainWindow)
+ invalidateAllClients()
+ const linked = linkOrphanWatchesToAccount(account.accountId)
+ const settings = getSettings()
+ if (settings.syncChannelsOnLogin) {
+ await syncAccountChannels(account.accountId)
+ }
+ if (linked > 0) {
+ console.info(`[Account] linked ${linked} orphan watches to ${account.accountId}`)
+ }
+ return listGoogleAccounts().find((a) => a.accountId === account.accountId) ?? account
+ }
+
+ remove(accountId: string): boolean {
+ return removeGoogleAccount(accountId)
+ }
+
+ setDefault(accountId: string): GoogleAccount | null {
+ return setDefaultAccount(accountId)
+ }
+
+ channels(accountId: string) {
+ return listManagedChannels(accountId)
+ }
+
+ async sync(accountId: string): Promise {
+ return syncAccountChannels(accountId)
+ }
+
+ watchChannel(accountId: string, channelId: string): WatchEntry {
+ return addWatchFromChannel(accountId, channelId)
+ }
+
+ watchAll(accountId: string): { added: number; skipped: number } {
+ return watchAllChannels(accountId)
+ }
+
+ async relogin(accountId: string): Promise {
+ const account = await openGoogleReloginWindow(accountId, this.mainWindow)
+ invalidateAllClients()
+ linkOrphanWatchesToAccount(account.accountId)
+ const settings = getSettings()
+ if (settings.syncChannelsOnLogin) {
+ await syncAccountChannels(account.accountId)
+ }
+ return listGoogleAccounts().find((a) => a.accountId === account.accountId) ?? account
+ }
+
+ async checkHealth(accountId: string) {
+ return checkAccountHealth(accountId)
+ }
+}
diff --git a/electron/src/youlive/main/services/account-store.ts b/electron/src/youlive/main/services/account-store.ts
new file mode 100644
index 0000000..6aad28a
--- /dev/null
+++ b/electron/src/youlive/main/services/account-store.ts
@@ -0,0 +1,469 @@
+import { eq, and, desc } from 'drizzle-orm'
+import { v4 as uuidv4 } from 'uuid'
+import { getDatabase, afterWrite } from '../db'
+import * as schema from '../db/schema'
+import type { GoogleAccount, ManagedChannel, AccountSyncResult } from '../../shared/account-types'
+import type { WatchEntry } from '../../shared/types'
+import { getSettings, setSettings, getWatches, updateWatch } from './store'
+import { fetchManagedChannels } from './youtube/channel-sync'
+import { invalidateAccountClient } from './youtube/client-pool'
+
+function rowToAccount(row: typeof schema.googleAccounts.$inferSelect): GoogleAccount {
+ return {
+ accountId: row.accountId,
+ label: row.label,
+ displayName: row.displayName ?? undefined,
+ email: row.email ?? undefined,
+ status: row.status as GoogleAccount['status'],
+ channelCount: row.channelCount,
+ isDefault: row.isDefault,
+ lastSyncAt: row.lastSyncAt ?? undefined,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt
+ }
+}
+
+function rowToChannel(row: typeof schema.managedChannels.$inferSelect): ManagedChannel {
+ return {
+ channelId: row.channelId,
+ accountId: row.accountId,
+ title: row.title,
+ handle: row.handle ?? undefined,
+ thumbnailUrl: row.thumbnailUrl ?? undefined,
+ isActiveOnAccount: Boolean(row.isActiveOnAccount),
+ isWatching: Boolean(row.isWatching),
+ syncedAt: row.syncedAt
+ }
+}
+
+export function listGoogleAccounts(): GoogleAccount[] {
+ return getDatabase()
+ .select()
+ .from(schema.googleAccounts)
+ .orderBy(desc(schema.googleAccounts.isDefault), desc(schema.googleAccounts.updatedAt))
+ .all()
+ .map(rowToAccount)
+}
+
+export function getGoogleAccount(accountId: string): GoogleAccount | null {
+ const row = getDatabase()
+ .select()
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .all()[0]
+ return row ? rowToAccount(row) : null
+}
+
+export function setAccountStatus(
+ accountId: string,
+ status: GoogleAccount['status']
+): void {
+ getDatabase()
+ .update(schema.googleAccounts)
+ .set({ status, updatedAt: new Date().toISOString() })
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .run()
+ afterWrite()
+}
+
+export function updateAccountCookie(
+ accountId: string,
+ cookieData: string
+): GoogleAccount | null {
+ const db = getDatabase()
+ const row = db
+ .select()
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .all()[0]
+ if (!row) return null
+
+ db.update(schema.googleAccounts)
+ .set({
+ cookieData,
+ status: 'active',
+ updatedAt: new Date().toISOString()
+ })
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .run()
+
+ invalidateAccountClient(accountId)
+ afterWrite()
+ return rowToAccount({ ...row, cookieData, status: 'active' })
+}
+
+export function getAccountCookieData(accountId: string): string | null {
+ const row = getDatabase()
+ .select({ cookieData: schema.googleAccounts.cookieData })
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .all()[0]
+ return row?.cookieData ?? null
+}
+
+export function getAccountPartitionId(accountId: string): string | null {
+ const row = getDatabase()
+ .select({ partitionId: schema.googleAccounts.partitionId })
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .all()[0]
+ return row?.partitionId ?? null
+}
+
+export function saveGoogleAccount(input: {
+ accountId: string
+ label: string
+ displayName?: string
+ email?: string
+ cookieData: string
+ partitionId: string
+ status?: GoogleAccount['status']
+ isDefault?: boolean
+}): GoogleAccount {
+ const db = getDatabase()
+ const now = new Date().toISOString()
+
+ if (input.isDefault) {
+ db.update(schema.googleAccounts).set({ isDefault: false }).run()
+ }
+
+ const existing = db
+ .select()
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, input.accountId))
+ .all()[0]
+
+ if (existing) {
+ db.update(schema.googleAccounts)
+ .set({
+ label: input.label,
+ displayName: input.displayName,
+ email: input.email,
+ cookieData: input.cookieData,
+ status: input.status ?? 'active',
+ isDefault: input.isDefault ?? existing.isDefault,
+ updatedAt: now
+ })
+ .where(eq(schema.googleAccounts.accountId, input.accountId))
+ .run()
+ } else {
+ db.insert(schema.googleAccounts)
+ .values({
+ accountId: input.accountId,
+ label: input.label,
+ displayName: input.displayName,
+ email: input.email,
+ cookieData: input.cookieData,
+ partitionId: input.partitionId,
+ status: input.status ?? 'active',
+ isDefault: input.isDefault ?? false,
+ channelCount: 0,
+ createdAt: now,
+ updatedAt: now
+ })
+ .run()
+ }
+
+ if (input.isDefault) {
+ setSettings({ activeAccountId: input.accountId })
+ }
+
+ afterWrite()
+ const row = db
+ .select()
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, input.accountId))
+ .all()[0]
+ return rowToAccount(row)
+}
+
+export function removeGoogleAccount(accountId: string): boolean {
+ const db = getDatabase()
+ db.delete(schema.managedChannels).where(eq(schema.managedChannels.accountId, accountId)).run()
+ db.delete(schema.googleAccounts).where(eq(schema.googleAccounts.accountId, accountId)).run()
+ invalidateAccountClient(accountId)
+
+ const settings = getSettings()
+ if (settings.activeAccountId === accountId) {
+ const next = listGoogleAccounts().find((a) => a.accountId !== accountId)
+ setSettings({ activeAccountId: next?.accountId ?? null })
+ }
+
+ afterWrite()
+ return true
+}
+
+export function setDefaultAccount(accountId: string): GoogleAccount | null {
+ const db = getDatabase()
+ const row = db
+ .select()
+ .from(schema.googleAccounts)
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .all()[0]
+ if (!row) return null
+
+ db.update(schema.googleAccounts).set({ isDefault: false }).run()
+ db.update(schema.googleAccounts)
+ .set({ isDefault: true, updatedAt: new Date().toISOString() })
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .run()
+
+ setSettings({ activeAccountId: accountId })
+ afterWrite()
+ return rowToAccount({ ...row, isDefault: true })
+}
+
+export function getActiveAccountId(): string | null {
+ return getSettings().activeAccountId
+}
+
+export function hasStoredAccountCookie(accountId: string): boolean {
+ return Boolean(getAccountCookieData(accountId))
+}
+
+export function getSafeActiveAccountId(excludeAccountIds: string[] = []): string | null {
+ const accountId = getActiveAccountId()
+ if (!accountId || excludeAccountIds.includes(accountId)) return null
+
+ const account = getGoogleAccount(accountId)
+ if (!account || account.status === 'expired') return null
+ if (!hasStoredAccountCookie(accountId)) return null
+
+ return accountId
+}
+
+/**
+ * Bot-challenge fallback account:
+ * - prefer active default account
+ * - only require stored cookie (ignore "expired" status, which may lag behind)
+ */
+export function getFallbackActiveAccountId(excludeAccountIds: string[] = []): string | null {
+ const accountId = getActiveAccountId()
+ if (!accountId || excludeAccountIds.includes(accountId)) return null
+
+ const account = getGoogleAccount(accountId)
+ if (!account) return null
+ if (!hasStoredAccountCookie(accountId)) return null
+
+ return accountId
+}
+
+/** 监听条目实际使用的账号:优先条目绑定,否则回退到默认已登录账号 */
+export function resolveWatchAccountId(watchAccountId?: string | null): string | undefined {
+ if (watchAccountId && hasStoredAccountCookie(watchAccountId)) {
+ return watchAccountId
+ }
+ return getFallbackActiveAccountId() ?? undefined
+}
+
+export function isYoutubeBotChallenge(err: unknown): boolean {
+ const message = err instanceof Error ? err.message : String(err)
+ return /Sign in to confirm you(?:'|')re not a bot|not a bot|LOGIN_REQUIRED/i.test(message)
+}
+
+export function linkOrphanWatchesToAccount(accountId: string): number {
+ let linked = 0
+ for (const watch of getWatches()) {
+ if (!watch.accountId) {
+ updateWatch(watch.watchId, { accountId })
+ linked++
+ }
+ }
+ return linked
+}
+
+export function listManagedChannels(accountId: string): ManagedChannel[] {
+ return getDatabase()
+ .select()
+ .from(schema.managedChannels)
+ .where(eq(schema.managedChannels.accountId, accountId))
+ .orderBy(desc(schema.managedChannels.isActiveOnAccount), schema.managedChannels.title)
+ .all()
+ .map(rowToChannel)
+}
+
+export function getAccountWatchCounts(): Array<{
+ accountId: string
+ label: string
+ watchCount: number
+ enabledCount: number
+}> {
+ const accounts = listGoogleAccounts()
+ const watches = getDatabase().select().from(schema.watches).all()
+ return accounts.map((acc) => {
+ const linked = watches.filter((w) => w.accountId === acc.accountId)
+ return {
+ accountId: acc.accountId,
+ label: acc.label,
+ watchCount: linked.length,
+ enabledCount: linked.filter((w) => w.enabled).length
+ }
+ })
+}
+
+export async function syncAccountChannels(accountId: string): Promise {
+ let channels: ManagedChannel[]
+ try {
+ channels = await fetchManagedChannels(accountId)
+ setAccountStatus(accountId, 'active')
+ } catch (err) {
+ invalidateAccountClient(accountId)
+ setAccountStatus(accountId, 'expired')
+ throw err
+ }
+ const db = getDatabase()
+ const now = new Date().toISOString()
+
+ const existingWatches = db.select().from(schema.watches).all()
+ const watchChannelIds = new Set(
+ existingWatches.filter((w) => w.kind === 'channel').map((w) => w.youtubeId)
+ )
+
+ db.delete(schema.managedChannels).where(eq(schema.managedChannels.accountId, accountId)).run()
+
+ for (const ch of channels) {
+ db.insert(schema.managedChannels)
+ .values({
+ channelId: ch.channelId,
+ accountId: ch.accountId,
+ title: ch.title,
+ handle: ch.handle,
+ thumbnailUrl: ch.thumbnailUrl,
+ isActiveOnAccount: ch.isActiveOnAccount,
+ isWatching: watchChannelIds.has(ch.channelId),
+ syncedAt: now
+ })
+ .run()
+ }
+
+ db.update(schema.googleAccounts)
+ .set({
+ channelCount: channels.length,
+ lastSyncAt: now,
+ updatedAt: now
+ })
+ .where(eq(schema.googleAccounts.accountId, accountId))
+ .run()
+
+ afterWrite()
+
+ return {
+ accountId,
+ channels: channels.map((c) => ({ ...c, isWatching: watchChannelIds.has(c.channelId), syncedAt: now })),
+ addedWatches: 0,
+ updated: channels.length
+ }
+}
+
+export function addWatchFromChannel(
+ accountId: string,
+ channelId: string,
+ pollIntervalSec?: number
+): WatchEntry {
+ const db = getDatabase()
+ const channel = db
+ .select()
+ .from(schema.managedChannels)
+ .where(
+ and(
+ eq(schema.managedChannels.accountId, accountId),
+ eq(schema.managedChannels.channelId, channelId)
+ )
+ )
+ .all()[0]
+
+ if (!channel) throw new Error('频道不存在,请先同步')
+
+ const existing = db
+ .select()
+ .from(schema.watches)
+ .where(and(eq(schema.watches.kind, 'channel'), eq(schema.watches.youtubeId, channelId)))
+ .all()[0]
+
+ if (existing) {
+ db.update(schema.watches)
+ .set({
+ accountId,
+ label: channel.title,
+ updatedAt: new Date().toISOString()
+ })
+ .where(eq(schema.watches.watchId, existing.watchId))
+ .run()
+ db.update(schema.managedChannels)
+ .set({ isWatching: true })
+ .where(
+ and(
+ eq(schema.managedChannels.accountId, accountId),
+ eq(schema.managedChannels.channelId, channelId)
+ )
+ )
+ .run()
+ afterWrite()
+ return {
+ watchId: existing.watchId,
+ kind: 'channel',
+ youtubeId: channelId,
+ label: channel.title,
+ enabled: existing.enabled,
+ pollIntervalSec: existing.pollIntervalSec,
+ accountId,
+ createdAt: existing.createdAt,
+ updatedAt: new Date().toISOString()
+ }
+ }
+
+ const now = new Date().toISOString()
+ const entry: WatchEntry = {
+ watchId: uuidv4(),
+ kind: 'channel',
+ youtubeId: channelId,
+ label: channel.title,
+ enabled: true,
+ pollIntervalSec: pollIntervalSec ?? getSettings().globalPollIntervalSec,
+ accountId,
+ createdAt: now,
+ updatedAt: now
+ }
+
+ db.insert(schema.watches).values({
+ watchId: entry.watchId,
+ kind: entry.kind,
+ youtubeId: entry.youtubeId,
+ label: entry.label,
+ enabled: entry.enabled,
+ pollIntervalSec: entry.pollIntervalSec,
+ accountId: entry.accountId,
+ createdAt: entry.createdAt,
+ updatedAt: entry.updatedAt
+ }).run()
+
+ db.update(schema.managedChannels)
+ .set({ isWatching: true })
+ .where(
+ and(
+ eq(schema.managedChannels.accountId, accountId),
+ eq(schema.managedChannels.channelId, channelId)
+ )
+ )
+ .run()
+
+ afterWrite()
+ return entry
+}
+
+export function watchAllChannels(accountId: string): { added: number; skipped: number } {
+ const channels = listManagedChannels(accountId)
+ let added = 0
+ let skipped = 0
+
+ for (const ch of channels) {
+ try {
+ addWatchFromChannel(accountId, ch.channelId)
+ added++
+ } catch {
+ skipped++
+ }
+ }
+
+ return { added, skipped }
+}
diff --git a/electron/src/youlive/main/services/device-detector/cache.ts b/electron/src/youlive/main/services/device-detector/cache.ts
new file mode 100644
index 0000000..cb13183
--- /dev/null
+++ b/electron/src/youlive/main/services/device-detector/cache.ts
@@ -0,0 +1,58 @@
+import { BrowserWindow } from 'electron'
+import type { DeviceOverview } from '../../../shared/device-types'
+import { IPC } from '../../../shared/types'
+import { getDeviceOverview } from './index'
+
+let cached: DeviceOverview | null = null
+let inflight: Promise | null = null
+const listeners = new Set<(overview: DeviceOverview) => void>()
+
+function notifyListeners(overview: DeviceOverview): void {
+ for (const listener of listeners) listener(overview)
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (!win.isDestroyed()) {
+ win.webContents.send(IPC.DEVICE_UPDATED, overview)
+ }
+ }
+}
+
+async function runScan(): Promise {
+ const overview = await getDeviceOverview()
+ cached = overview
+ notifyListeners(overview)
+ return overview
+}
+
+/** 应用启动时后台预扫描 */
+export function preloadDeviceOverview(): void {
+ if (cached || inflight) return
+ inflight = runScan().finally(() => {
+ inflight = null
+ })
+}
+
+/** 返回缓存;有缓存时后台静默刷新 */
+export async function getCachedDeviceOverview(): Promise {
+ if (cached) {
+ if (!inflight) {
+ inflight = runScan().finally(() => {
+ inflight = null
+ })
+ }
+ return cached
+ }
+ if (inflight) return inflight
+ inflight = runScan().finally(() => {
+ inflight = null
+ })
+ return inflight
+}
+
+export function onDeviceOverviewUpdated(cb: (overview: DeviceOverview) => void): () => void {
+ listeners.add(cb)
+ return () => listeners.delete(cb)
+}
+
+export function getDeviceOverviewSnapshot(): DeviceOverview | null {
+ return cached
+}
diff --git a/electron/src/youlive/main/services/device-detector/device-list.ts b/electron/src/youlive/main/services/device-detector/device-list.ts
new file mode 100644
index 0000000..54f2069
--- /dev/null
+++ b/electron/src/youlive/main/services/device-detector/device-list.ts
@@ -0,0 +1,96 @@
+import { resolveFfmpeg, spawnResolved } from '../resolve-binary'
+import type { CaptureDevice } from '../../../shared/device-types'
+
+function parseFfmpegDevices(stderr: string): CaptureDevice[] {
+ const devices: CaptureDevice[] = []
+ const lines = stderr.split(/\r?\n/)
+ let inVideo = false
+ let inAudio = false
+
+ for (const line of lines) {
+ if (/DirectShow video devices/i.test(line) || /video devices/i.test(line)) {
+ inVideo = true
+ inAudio = false
+ continue
+ }
+ if (/DirectShow audio devices/i.test(line) || /audio devices/i.test(line)) {
+ inAudio = true
+ inVideo = false
+ continue
+ }
+ if (/^\[/.test(line) && !/\] "(.*)"/.test(line)) {
+ inVideo = false
+ inAudio = false
+ }
+
+ const m = line.match(/\] "(.*)" \((video|audio)\)/)
+ if (!m) continue
+ const name = m[1]
+ const kind = m[2] as 'video' | 'audio'
+ if (kind === 'audio' && inAudio) {
+ devices.push({ id: name, name, kind })
+ } else if (kind === 'video' && inVideo) {
+ devices.push({ id: name, name, kind })
+ }
+ }
+
+ return devices
+}
+
+function parseV4l2Devices(stderr: string): CaptureDevice[] {
+ const devices: CaptureDevice[] = []
+ for (const line of stderr.split(/\r?\n/)) {
+ const m = line.match(/\] \/dev\/(video\d+)/)
+ if (m) {
+ const id = `/dev/${m[1]}`
+ devices.push({ id, name: id, kind: 'video' })
+ }
+ }
+ return devices
+}
+
+function parseAvfoundationDevices(stderr: string): CaptureDevice[] {
+ const devices: CaptureDevice[] = []
+ for (const line of stderr.split(/\r?\n/)) {
+ const m = line.match(/\[(\d+)\] (.+)/)
+ if (m && /AVFoundation indev|video devices/i.test(stderr)) {
+ devices.push({ id: m[1], name: m[2].trim(), kind: 'video' })
+ }
+ }
+ return devices
+}
+
+async function listWithFfmpeg(args: string[]): Promise {
+ const ffmpeg = await resolveFfmpeg()
+ if (!ffmpeg) return ''
+
+ return new Promise((resolve) => {
+ const proc = spawnResolved(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] })
+ let stderr = ''
+ proc.stderr?.on('data', (d: Buffer) => {
+ stderr += d.toString()
+ })
+ proc.on('close', () => resolve(stderr))
+ proc.on('error', () => resolve(stderr))
+ setTimeout(() => {
+ proc.kill('SIGTERM')
+ resolve(stderr)
+ }, 12_000)
+ })
+}
+
+export async function listCaptureDevices(): Promise {
+ if (process.platform === 'win32') {
+ const stderr = await listWithFfmpeg(['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'])
+ return parseFfmpegDevices(stderr)
+ }
+ if (process.platform === 'linux') {
+ const stderr = await listWithFfmpeg(['-hide_banner', '-list_devices', 'true', '-f', 'v4l2', '-i', 'dummy'])
+ return parseV4l2Devices(stderr)
+ }
+ if (process.platform === 'darwin') {
+ const stderr = await listWithFfmpeg(['-hide_banner', '-f', 'avfoundation', '-list_devices', 'true', '-i', ''])
+ return parseAvfoundationDevices(stderr)
+ }
+ return []
+}
diff --git a/electron/src/youlive/main/services/device-detector/index.ts b/electron/src/youlive/main/services/device-detector/index.ts
new file mode 100644
index 0000000..17973a7
--- /dev/null
+++ b/electron/src/youlive/main/services/device-detector/index.ts
@@ -0,0 +1,63 @@
+import os from 'os'
+import { app, screen } from 'electron'
+import { resolveFfmpeg } from '../resolve-binary'
+import type { DeviceOverview } from '../../../shared/device-types'
+import { listCaptureDevices } from './device-list'
+import { getNetworkOverview } from './network-detector'
+
+function platformLabel(platform: NodeJS.Platform): string {
+ if (platform === 'win32') return 'Windows'
+ if (platform === 'darwin') return 'macOS'
+ if (platform === 'linux') return 'Linux'
+ return platform
+}
+
+export async function getDeviceOverview(): Promise {
+ const cpus = os.cpus()
+ const totalMem = os.totalmem()
+ const freeMem = os.freemem()
+ const usedMem = totalMem - freeMem
+ const primaryId = screen.getPrimaryDisplay().id
+
+ const [ffmpegAvailable, captureDevices, network] = await Promise.all([
+ resolveFfmpeg().then(Boolean),
+ listCaptureDevices(),
+ getNetworkOverview()
+ ])
+
+ return {
+ scannedAt: new Date().toISOString(),
+ system: {
+ platform: process.platform,
+ osRelease: os.release(),
+ osVersion: typeof os.version === 'function' ? os.version() : platformLabel(process.platform),
+ arch: os.arch(),
+ hostname: os.hostname(),
+ uptimeSec: os.uptime(),
+ cpuModel: cpus[0]?.model?.trim() || '未知处理器',
+ cpuCores: cpus.length,
+ cpuSpeedMhz: cpus[0]?.speed ?? 0,
+ totalMemBytes: totalMem,
+ freeMemBytes: freeMem,
+ usedMemBytes: usedMem,
+ memUsagePercent: totalMem > 0 ? (usedMem / totalMem) * 100 : 0
+ },
+ app: {
+ electronVersion: process.versions.electron,
+ nodeVersion: process.versions.node,
+ chromeVersion: process.versions.chrome,
+ appVersion: app.getVersion()
+ },
+ displays: screen.getAllDisplays().map((d) => ({
+ id: d.id,
+ label: d.label || `显示器 ${d.id}`,
+ width: d.size.width,
+ height: d.size.height,
+ scaleFactor: d.scaleFactor,
+ primary: d.id === primaryId
+ })),
+ network,
+ ffmpegAvailable,
+ captureDevices
+ }
+}
diff --git a/electron/src/youlive/main/services/device-detector/network-detector.ts b/electron/src/youlive/main/services/device-detector/network-detector.ts
new file mode 100644
index 0000000..1b86add
--- /dev/null
+++ b/electron/src/youlive/main/services/device-detector/network-detector.ts
@@ -0,0 +1,371 @@
+import os from 'os'
+import dns from 'dns'
+import { execFile } from 'child_process'
+import { promisify } from 'util'
+import type {
+ NetworkAddressInfo,
+ NetworkConnectivity,
+ NetworkInterfaceInfo,
+ NetworkOverview,
+ WifiInfo
+} from '../../../shared/device-types'
+
+const execFileAsync = promisify(execFile)
+
+function detectInterfaceType(name: string): NetworkInterfaceInfo['type'] {
+ const n = name.toLowerCase()
+ if (/loopback|^lo$/.test(n)) return 'loopback'
+ if (/wi-?fi|wlan|wireless|802\.11/.test(n)) return 'wifi'
+ if (/vethernet|virtual|vmware|vbox|hyper-v|tap|tun|docker|wsl|npcap|bluetooth/.test(n)) {
+ return 'virtual'
+ }
+ if (/ethernet|eth|en\d|本地连接/.test(n)) return 'ethernet'
+ return 'other'
+}
+
+function cidrFromNetmask(netmask: string, family: 'IPv4' | 'IPv6'): string | undefined {
+ if (family !== 'IPv4' || !netmask) return undefined
+ const parts = netmask.split('.').map(Number)
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return undefined
+ const bits = parts.reduce((acc, oct) => acc + oct.toString(2).padStart(8, '0').replace(/0/g, '').length, 0)
+ return `/${bits}`
+}
+
+function parseNetworkInterfaces(): NetworkInterfaceInfo[] {
+ const raw = os.networkInterfaces()
+ const result: NetworkInterfaceInfo[] = []
+
+ for (const [name, addrs] of Object.entries(raw)) {
+ if (!addrs?.length) continue
+ const addresses: NetworkAddressInfo[] = addrs.map((a) => ({
+ address: a.address,
+ family: a.family as 'IPv4' | 'IPv6',
+ internal: a.internal,
+ netmask: a.netmask,
+ cidr: a.cidr ?? (a.netmask ? cidrFromNetmask(a.netmask, a.family as 'IPv4' | 'IPv6') : undefined),
+ scopeid: a.scopeid
+ }))
+ const mac = addrs.find((a) => a.mac && a.mac !== '00:00:00:00:00:00')?.mac
+ result.push({
+ name,
+ type: detectInterfaceType(name),
+ mac,
+ addresses,
+ operstate: addresses.some((a) => !a.internal) ? 'up' : 'down'
+ })
+ }
+
+ return result.sort((a, b) => {
+ const rank = (t: NetworkInterfaceInfo['type']) =>
+ ({ ethernet: 0, wifi: 1, other: 2, virtual: 3, loopback: 4 })[t]
+ return rank(a.type) - rank(b.type)
+ })
+}
+
+async function getDefaultGateway(): Promise {
+ try {
+ if (process.platform === 'win32') {
+ const { stdout } = await execFileAsync('route', ['print', '0.0.0.0'], {
+ encoding: 'utf8',
+ windowsHide: true,
+ maxBuffer: 512 * 1024
+ })
+ for (const line of stdout.split(/\r?\n/)) {
+ const trimmed = line.trim()
+ if (trimmed.startsWith('0.0.0.0') && !trimmed.includes('On-link')) {
+ const parts = trimmed.split(/\s+/)
+ if (parts.length >= 3 && parts[2] !== '0.0.0.0') return parts[2]
+ }
+ }
+ } else if (process.platform === 'darwin') {
+ const { stdout } = await execFileAsync('route', ['-n', 'get', 'default'], { encoding: 'utf8' })
+ const m = stdout.match(/gateway:\s*(\S+)/)
+ return m?.[1] ?? null
+ } else {
+ try {
+ const { stdout } = await execFileAsync('ip', ['route', 'show', 'default'], { encoding: 'utf8' })
+ const m = stdout.match(/default via (\S+)/)
+ if (m) return m[1]
+ } catch {
+ const { stdout } = await execFileAsync('route', ['-n'], { encoding: 'utf8' })
+ for (const line of stdout.split('\n')) {
+ if (line.startsWith('0.0.0.0')) {
+ const parts = line.trim().split(/\s+/)
+ if (parts.length >= 2) return parts[1]
+ }
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return null
+}
+
+async function getWifiInfo(): Promise {
+ if (process.platform === 'win32') {
+ try {
+ const { stdout } = await execFileAsync('netsh', ['wlan', 'show', 'interfaces'], {
+ encoding: 'utf8',
+ windowsHide: true
+ })
+ const block: Record = {}
+ for (const line of stdout.split(/\r?\n/)) {
+ const m = line.match(/^\s*([^:]+):\s*(.*)$/)
+ if (m) block[m[1].trim().toLowerCase()] = m[2].trim()
+ }
+ const state = block.state ?? block['状态'] ?? ''
+ const connected = /connected|已连接/i.test(state)
+ const signalRaw = block.signal ?? block['信号'] ?? ''
+ const signalMatch = signalRaw.match(/(\d+)\s*%/)
+ const ssid = block.ssid ?? block['ssid'] ?? undefined
+ if (!ssid && !connected) return null
+ return {
+ connected,
+ ssid: ssid || undefined,
+ bssid: block.bssid ?? undefined,
+ signalPercent: signalMatch ? Number(signalMatch[1]) : undefined,
+ channel: block.channel ?? block['频道'] ?? undefined,
+ radioType: block['radio type'] ?? block['无线电类型'] ?? undefined,
+ auth: block.authentication ?? block['身份验证'] ?? undefined,
+ profile: block.profile ?? block['配置文件'] ?? undefined
+ }
+ } catch {
+ return null
+ }
+ }
+
+ if (process.platform === 'darwin') {
+ try {
+ const { stdout } = await execFileAsync(
+ '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport',
+ ['-I'],
+ { encoding: 'utf8' }
+ )
+ const fields: Record = {}
+ for (const line of stdout.split('\n')) {
+ const m = line.match(/^\s*([^:]+):\s*(.*)$/)
+ if (m) fields[m[1].trim()] = m[2].trim()
+ }
+ if (!fields.SSID) return null
+ return {
+ connected: fields.state === 'running' || !!fields.SSID,
+ ssid: fields.SSID,
+ bssid: fields.BSSID,
+ signalDbm: fields.RSSI ? Number(fields.RSSI) : undefined,
+ channel: fields.channel,
+ radioType: fields['link auth'] ?? fields['op mode']
+ }
+ } catch {
+ return null
+ }
+ }
+
+ if (process.platform === 'linux') {
+ try {
+ const { stdout } = await execFileAsync('iwgetid', ['-r'], { encoding: 'utf8' })
+ const ssid = stdout.trim()
+ if (!ssid) return null
+ let signalPercent: number | undefined
+ try {
+ const { stdout: linkOut } = await execFileAsync('iw', ['dev'], { encoding: 'utf8' })
+ const ifMatch = linkOut.match(/^Interface (\S+)/m)
+ if (ifMatch) {
+ const { stdout: info } = await execFileAsync('iw', ['dev', ifMatch[1], 'link'], {
+ encoding: 'utf8'
+ })
+ const sig = info.match(/signal: (-?\d+)/)
+ if (sig) {
+ const dbm = Number(sig[1])
+ signalPercent = Math.min(100, Math.max(0, 2 * (dbm + 100)))
+ }
+ }
+ } catch {
+ /* optional */
+ }
+ return { connected: true, ssid, signalPercent }
+ } catch {
+ return null
+ }
+ }
+
+ return null
+}
+
+async function fetchPublicIp(): Promise<{ ip: string | null; error?: string }> {
+ const endpoints = [
+ { url: 'https://api.ipify.org?format=json', json: true },
+ { url: 'https://ifconfig.me/ip', json: false }
+ ]
+ for (const { url, json } of endpoints) {
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(6000) })
+ if (!res.ok) continue
+ if (json) {
+ const data = (await res.json()) as { ip?: string }
+ if (data.ip) return { ip: data.ip }
+ } else {
+ const text = (await res.text()).trim()
+ if (text) return { ip: text }
+ }
+ } catch {
+ /* try next */
+ }
+ }
+ return { ip: null, error: '无法连接公网 IP 查询服务' }
+}
+
+async function measureLatency(host = '1.1.1.1'): Promise {
+ try {
+ const args =
+ process.platform === 'win32'
+ ? ['-n', '1', '-w', '3000', host]
+ : process.platform === 'darwin'
+ ? ['-c', '1', '-W', '3000', host]
+ : ['-c', '1', '-W', '3', host]
+ const { stdout } = await execFileAsync('ping', args, {
+ encoding: 'utf8',
+ windowsHide: true
+ })
+ const m =
+ stdout.match(/[=<]\s*(\d+)\s*ms/i) ??
+ stdout.match(/time[=<](\d+\.?\d*)\s*ms/i) ??
+ stdout.match(/time=(\d+\.?\d*)/i)
+ return m ? Math.round(parseFloat(m[1])) : null
+ } catch {
+ return null
+ }
+}
+
+async function testDnsResolve(): Promise {
+ try {
+ await dns.promises.lookup('www.youtube.com')
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function getWindowsAdapterDetails(): Promise