This commit is contained in:
eric
2026-03-24 11:56:11 -05:00
parent e6eb1c075b
commit 9e812215d2
16 changed files with 1797 additions and 155 deletions

View File

@@ -149,12 +149,12 @@ function resolvePreload(): string {
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 920,
height: 860,
minWidth: 640,
width: 1020,
height: 720,
minWidth: 720,
minHeight: 560,
show: false,
backgroundColor: '#f0f0f0',
backgroundColor: '#14161c',
title: '直播录制助手',
autoHideMenuBar: true,
webPreferences: {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<meta name="color-scheme" content="dark" />
<title>直播录制助手</title>
</head>
<body>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,22 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { apiUrl } from './api'
import NetworkDashboard from './NetworkDashboard'
import ProxySettings from './ProxySettings'
import SystemMetrics from './SystemMetrics'
import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
type Entry = { script: string; label: string; pm2: string }
type NavId = 'record' | 'network' | 'proxy' | 'system'
const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [
{ id: 'record', label: '录制', hint: '任务与推流控制' },
{ id: 'network', label: '网络', hint: '连通与测速' },
{ id: 'proxy', label: '代理', hint: '仅录制进程' },
{ id: 'system', label: '系统', hint: '负载与网速' },
]
type StatusVariant =
| 'loading'
| 'online'
@@ -49,6 +61,8 @@ export default function App() {
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
const [nav, setNav] = useState<NavId>('record')
const logRef = useRef<HTMLPreElement>(null)
const scrollLog = useCallback(() => {
@@ -249,18 +263,26 @@ export default function App() {
if (bootError) {
return (
<div className="app-frame">
<div className="app-chrome">
<div className="app-chrome__title"></div>
</div>
<div className="app-body app-body--padded">
<div className="error-card" role="alert">
<div className="error-card__icon" aria-hidden>
!
</div>
<div className="app-frame app-frame--verge">
<aside className="app-sidebar" aria-label="主导航">
<div className="app-sidebar__brand">
<div className="app-sidebar__logo" aria-hidden />
<div>
<h2 className="error-card__title"></h2>
<pre className="error-card__body">{bootError}</pre>
<div className="app-sidebar__app-name"></div>
<div className="app-sidebar__app-tag"></div>
</div>
</div>
</aside>
<div className="app-workspace">
<div className="app-workspace__inner app-workspace__inner--center">
<div className="error-card" role="alert">
<div className="error-card__icon" aria-hidden>
!
</div>
<div>
<h2 className="error-card__title"></h2>
<pre className="error-card__body">{bootError}</pre>
</div>
</div>
</div>
</div>
@@ -268,14 +290,73 @@ export default function App() {
)
}
return (
<div className="app-frame">
<header className="app-chrome">
<div className="app-chrome__title"></div>
<p className="app-chrome__subtitle"></p>
</header>
const navMeta = NAV_ITEMS.find((n) => n.id === nav)
<div className="app-body">
return (
<div className="app-frame app-frame--verge">
<aside className="app-sidebar" aria-label="主导航">
<div className="app-sidebar__brand">
<div className="app-sidebar__logo" aria-hidden />
<div>
<div className="app-sidebar__app-name"></div>
<div className="app-sidebar__app-tag"> · </div>
</div>
</div>
<nav className="app-sidebar__nav">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={`app-sidebar__link ${nav === item.id ? 'is-active' : ''}`}
onClick={() => setNav(item.id)}
title={item.hint}
>
<span className="app-sidebar__link-label">{item.label}</span>
{nav === item.id && <span className="app-sidebar__link-glow" aria-hidden />}
</button>
))}
</nav>
</aside>
<div className="app-workspace">
<header className="app-workspace__header">
<div>
<h1 className="app-workspace__title">{navMeta?.label ?? ''}</h1>
<p className="app-workspace__subtitle">{navMeta?.hint}</p>
</div>
{nav === 'record' && (
<div
id="statusIndicator"
className="status-pill"
data-variant={statusMeta.variant}
role="status"
aria-live="polite"
>
<span className="status-pill__dot" aria-hidden />
{statusMeta.line}
</div>
)}
</header>
<div className="app-workspace__scroll">
{nav === 'network' && (
<div className="app-panel">
<NetworkDashboard />
</div>
)}
{nav === 'proxy' && (
<div className="app-panel">
<ProxySettings />
</div>
)}
{nav === 'system' && (
<div className="app-panel">
<SystemMetrics />
</div>
)}
{nav === 'record' && (
<div className="app-body app-body--record">
<div className="app-toolbar">
<div className="app-toolbar__left">
<label className="field-label" htmlFor="processSelect">
@@ -296,16 +377,6 @@ export default function App() {
))}
</select>
</div>
<div
id="statusIndicator"
className="status-pill"
data-variant={statusMeta.variant}
role="status"
aria-live="polite"
>
<span className="status-pill__dot" aria-hidden />
{statusMeta.line}
</div>
</div>
{initialLoad && (
@@ -474,6 +545,9 @@ export default function App() {
</section>
</main>
)}
</div>
)}
</div>
</div>
</div>
)

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
type SiteInfo = {
host: string
reachable: boolean
latency_ms: number
error: string | null
resolved_ip: string | null
country: string | null
country_code: string | null
geo_error: string | null
}
type SpeedBlock = {
label: string
download_mbps: number | null
download_error: string | null
download_bytes_sampled: number
upload_mbps: number | null
upload_error: string | null
upload_target: string | null
}
type NetworkPayload = {
google: SiteInfo
douyin: SiteInfo
routes: { different: boolean | null; note: string }
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
error?: string
}
function fmtMbps(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
return `${v.toFixed(2)} Mbps`
}
function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
const ok = site.reachable
return (
<div className="net-site">
<div className="net-site__head">
<span className="net-site__title">{title}</span>
<span className={`net-badge ${ok ? 'net-badge--ok' : 'net-badge--bad'}`}>
{ok ? '可访问' : '不可访问'}
</span>
</div>
<div className="net-site__grid">
<span className="net-k"></span>
<span className="net-v">{site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'}</span>
<span className="net-k"> IP</span>
<span className="net-v net-v--mono">{site.resolved_ip ?? '—'}</span>
<span className="net-k"></span>
<span className="net-v">
{site.country ?? '—'}
{site.country_code ? ` (${site.country_code})` : ''}
</span>
</div>
{(site.error || site.geo_error) && (
<p className="net-site__err">
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
</p>
)}
</div>
)
}
export default function NetworkDashboard() {
const [data, setData] = useState<NetworkPayload | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setErr(null)
try {
const res = await fetch(apiUrl('/network_status'))
const json = (await res.json()) as NetworkPayload & { error?: string }
if (!res.ok) {
setErr(json.error || `请求失败 ${res.status}`)
setData(null)
return
}
setData(json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setData(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
const id = window.setInterval(() => void load(), 45000)
return () => clearInterval(id)
}, [load])
return (
<section className="card card--network" aria-labelledby="net-dash-title">
<div className="net-dash__header">
<div>
<h2 className="card__title" id="net-dash-title">
</h2>
<p className="card__desc net-dash__lead">
Google线 0.6MB
/ 256KB
</p>
</div>
<button type="button" className="btn btn-secondary net-dash__refresh" disabled={loading} onClick={() => void load()}>
{loading ? '检测中…' : '重新检测'}
</button>
</div>
{err && (
<p className="net-dash__error" role="alert">
{err}
</p>
)}
{loading && !data && !err && <div className="net-dash__loading"></div>}
{data && (
<>
<div className="net-site-row">
<SiteRow title="Google海外参考" site={data.google} />
<SiteRow title="抖音(国内参考)" site={data.douyin} />
</div>
<div className="net-routes">
<span className="net-routes__label">线线</span>
<span
className={`net-routes__pill ${
data.routes.different === true
? 'net-routes__pill--yes'
: data.routes.different === false
? 'net-routes__pill--no'
: 'net-routes__pill--unk'
}`}
>
{data.routes.different === true
? '是(解析国家不同)'
: data.routes.different === false
? '否或不确定'
: '未知'}
</span>
<p className="net-routes__note">{data.routes.note}</p>
</div>
<div className="net-speed">
<h3 className="net-speed__title"></h3>
<div className="net-speed__grid">
<div className="net-speed__col">
<div className="net-speed__head"></div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(data.speed.overseas.download_mbps)}</span>
</div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(data.speed.overseas.upload_mbps)}</span>
</div>
{(data.speed.overseas.download_error || data.speed.overseas.upload_error) && (
<p className="net-speed__hint">
{[data.speed.overseas.download_error, data.speed.overseas.upload_error]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
<div className="net-speed__col">
<div className="net-speed__head"></div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(data.speed.domestic.download_mbps)}</span>
</div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(data.speed.domestic.upload_mbps)}</span>
</div>
{(data.speed.domestic.download_error || data.speed.domestic.upload_error) && (
<p className="net-speed__hint">
{[data.speed.domestic.download_error, data.speed.domestic.upload_error]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
</div>
<p className="net-speed__foot">
Google / httpbin
POST
</p>
</div>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
type ProxyCfg = { enabled: boolean; http: string; socks: string }
export default function ProxySettings() {
const [cfg, setCfg] = useState<ProxyCfg>({ enabled: false, http: '', socks: '' })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'))
const data = (await res.json()) as ProxyCfg
if (!res.ok) throw new Error('读取失败')
setCfg({
enabled: !!data.enabled,
http: data.http || '',
socks: data.socks || '',
})
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const save = async () => {
setSaving(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
})
if (!res.ok) throw new Error('保存失败')
setMsg('已保存。重启录制任务后生效。')
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<section className="card card--proxy" aria-labelledby="proxy-settings-title">
<h2 className="card__title" id="proxy-settings-title">
</h2>
<p className="card__desc">
FFmpeg / Windows
使 Clash Verge 7890
YouTube RTMP TUN
</p>
{loading ? (
<p className="net-dash__loading"></p>
) : (
<div className="proxy-form">
<label className="proxy-check">
<input
type="checkbox"
checked={cfg.enabled}
onChange={(e) => setCfg((c) => ({ ...c, enabled: e.target.checked }))}
/>
<span>使</span>
</label>
<label className="field-label" htmlFor="proxyHttp">
HTTP FFmpeg -http_proxy
</label>
<input
id="proxyHttp"
className="proxy-input"
type="text"
placeholder="http://127.0.0.1:7890"
value={cfg.http}
onChange={(e) => setCfg((c) => ({ ...c, http: e.target.value }))}
autoComplete="off"
/>
<label className="field-label" htmlFor="proxySocks">
SOCKS5 ALL_PROXY
</label>
<input
id="proxySocks"
className="proxy-input"
type="text"
placeholder="socks5://127.0.0.1:7890"
value={cfg.socks}
onChange={(e) => setCfg((c) => ({ ...c, socks: e.target.value }))}
autoComplete="off"
/>
<div className="btn-row">
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
{saving ? '保存中…' : '保存'}
</button>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
</button>
</div>
{msg && <p className="proxy-form__msg">{msg}</p>}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
type Metrics = {
ts?: number
cpu_percent?: number | null
cpu_count_logical?: number
memory?: { percent?: number; used?: number; total?: number; available?: number }
swap?: { percent?: number; used?: number; total?: number }
disks?: Array<{
mountpoint?: string
percent?: number
free?: number
total?: number
}>
network?: { up_mbps?: number | null; down_mbps?: number | null }
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
error?: string
}
function fmtBytes(n: number | undefined): string {
if (n == null || Number.isNaN(n)) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
return `${(n / 1024 ** 3).toFixed(2)} GB`
}
export default function SystemMetrics() {
const [data, setData] = useState<Metrics | null>(null)
const [err, setErr] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await fetch(apiUrl('/system_metrics'))
const json = (await res.json()) as Metrics
if (!res.ok) {
setErr(json.error || `HTTP ${res.status}`)
return
}
setErr(null)
setData(json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
void load()
const id = window.setInterval(() => void load(), 2000)
return () => clearInterval(id)
}, [load])
const m = data?.memory
const net = data?.network
return (
<section className="card card--metrics" aria-labelledby="sys-metrics-title">
<h2 className="card__title" id="sys-metrics-title">
</h2>
<p className="card__desc">
2
ffmpeg
</p>
{err && (
<p className="net-dash__error" role="alert">
{err}
</p>
)}
{!data && !err && <p className="net-dash__loading"></p>}
{data && (
<div className="metrics-grid">
<div className="metrics-block">
<div className="metrics-block__title">CPU</div>
<div className="metrics-big">
{data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
</div>
<div className="metrics-sub"> {data.cpu_count_logical ?? '—'} </div>
</div>
<div className="metrics-block">
<div className="metrics-block__title"></div>
<div className="metrics-big">{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</div>
<div className="metrics-sub">
{fmtBytes(m?.used)} / {fmtBytes(m?.total)} {fmtBytes(m?.available)}
</div>
</div>
<div className="metrics-block">
<div className="metrics-block__title"></div>
<div className="metrics-rate">
<span> {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}</span>
<span> {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}</span>
</div>
<div className="metrics-sub"></div>
</div>
</div>
)}
{data?.swap && data.swap.total != null && data.swap.total > 0 && (
<div className="metrics-swap">
{data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
</div>
)}
{data?.disks && data.disks.length > 0 && (
<div className="metrics-disks">
<div className="metrics-disks__title"></div>
<ul className="metrics-disk-list">
{data.disks.slice(0, 8).map((d) => (
<li key={d.mountpoint}>
<span className="metrics-disk-list__mp">{d.mountpoint}</span>
<span>
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} · {fmtBytes(d.free)}
</span>
</li>
))}
</ul>
</div>
)}
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 && (
<div className="metrics-ff">
<div className="metrics-ff__title">ffmpeg </div>
<table className="metrics-table">
<thead>
<tr>
<th>PID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.ffmpeg_processes.map((p) => (
<tr key={p.pid}>
<td>{p.pid}</td>
<td>{p.name}</td>
<td>{fmtBytes(p.rss)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}