This commit is contained in:
eric
2026-03-25 04:14:22 -05:00
parent f6912cff20
commit 17ba5a83d1
9 changed files with 107 additions and 288 deletions

View File

@@ -1,8 +1,8 @@
{
"name": "live-recorder-console",
"productName": "直播录制助手",
"productName": "无人直播助手",
"version": "1.0.0",
"description": "直播录制助手Windows 懒人包)",
"description": "无人直播助手",
"main": "./out/main/index.js",
"type": "module",
"scripts": {
@@ -46,7 +46,7 @@
},
"build": {
"appId": "com.douyin.recorder.console",
"productName": "直播录制控制台",
"productName": "无人直播助手",
"directories": {
"output": "dist",
"buildResources": "build"

View File

@@ -57,7 +57,7 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void {
}
const ver = app.getVersion()
const desc = readPackageJsonField('description') || '直播录制与转播控制台'
const desc = readPackageJsonField('description') || '无人直播与转播控制台'
app.setAboutPanelOptions({
applicationName: app.getName(),
applicationVersion: ver,
@@ -85,14 +85,6 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void {
if (process.platform === 'win32') {
try {
app.setUserTasks([
{
program: process.execPath,
arguments: '--nav=desk',
title: '工作台',
description: '打开工作台仪表盘',
iconPath: process.execPath,
iconIndex: 0,
},
{
program: process.execPath,
arguments: '--nav=record',

View File

@@ -181,7 +181,7 @@ function resolveWeb2Host(): string {
} catch {
/* ignore */
}
return '127.0.0.1'
return '0.0.0.0'
}
function resolveWeb2ApiTokenForChild(): string | undefined {
@@ -323,7 +323,7 @@ function startPythonBackend(backendRoot: string, pythonExe: string): void {
const apiTok = resolveWeb2ApiTokenForChild()
if (web2Host === '0.0.0.0' && !apiTok) {
console.warn(
'[直播录制助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txtAPI 无鉴权,请勿暴露到公网。',
'[无人直播助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txtAPI 无鉴权,请勿暴露到公网。',
)
}
const env = {
@@ -351,7 +351,7 @@ function startPythonBackend(backendRoot: string, pythonExe: string): void {
pythonChild.on('close', (code) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (code !== 0 && code !== null) {
dialog.showErrorBox('直播录制助手', `软件内部服务已停止运行。\n\n${stderrTail()}`)
dialog.showErrorBox('无人直播助手', `软件内部服务已停止运行。\n\n${stderrTail()}`)
}
})
}
@@ -391,7 +391,7 @@ async function createWindow(): Promise<void> {
minHeight: 560,
show: false,
backgroundColor: '#14161c',
title: '直播录制助手',
title: '无人直播助手',
autoHideMenuBar: true,
webPreferences: {
preload: resolvePreload(),
@@ -413,7 +413,7 @@ async function createWindow(): Promise<void> {
try {
await waitForDevServer(devUrl)
} catch (e) {
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
dialog.showErrorBox('无人直播助手', e instanceof Error ? e.message : String(e))
mainWindow.destroy()
mainWindow = null
return
@@ -431,7 +431,7 @@ async function createWindow(): Promise<void> {
} catch {
if (i === 7) {
dialog.showErrorBox(
'直播录制助手',
'无人直播助手',
`无法加载开发页面:${devUrl}\n请确认 Vite 已启动且端口未被占用。`,
)
mainWindow.destroy()
@@ -442,7 +442,7 @@ async function createWindow(): Promise<void> {
}
}
} catch (e) {
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
dialog.showErrorBox('无人直播助手', e instanceof Error ? e.message : String(e))
mainWindow.destroy()
mainWindow = null
return
@@ -655,6 +655,9 @@ if (!gotLock) {
if (typeof enabled !== 'boolean') {
return { ok: false, error: '参数无效' }
}
if (enabled === false) {
return { ok: false, error: '本应用固定开启局域网 / 虚拟网访问,无法关闭。' }
}
const br = _backendRoot ?? getBackendRoot()
const cfgDir = path.join(br, 'config')
fs.mkdirSync(cfgDir, { recursive: true })
@@ -685,7 +688,7 @@ if (!gotLock) {
if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) {
dialog.showErrorBox(
'直播录制助手',
'无人直播助手',
`未找到程序必要文件,当前安装可能不完整。\n\n路径${backendRoot}\n\n开发调试请在项目根目录保留 web2.py并在 electron 目录执行 pnpm dev。`,
)
app.quit()
@@ -698,7 +701,7 @@ if (!gotLock) {
await waitForHealth()
} catch (e) {
dialog.showErrorBox(
'直播录制助手',
'无人直播助手',
`软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`,
)
killPythonTree()

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>直播录制助手</title>
<title>无人直播助手</title>
<script>
try {
var t = localStorage.getItem('theme')

View File

@@ -1,4 +1,4 @@
/* 直播录制助手 — 借鉴 Clash Verge 类客户端:深色侧栏 + 主工作区(离线无外链字体) */
/* 无人直播助手 — 借鉴 Clash Verge 类客户端:深色侧栏 + 主工作区(离线无外链字体) */
:root {
--font-ui: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;

View File

@@ -70,7 +70,7 @@ function SidebarBrand({
<div className="app-sidebar__logo" aria-hidden />
<div className="app-sidebar__brand-main">
<div className="app-sidebar__brand-title-row">
<span className="app-sidebar__app-name"></span>
<span className="app-sidebar__app-name"></span>
<button
type="button"
className="app-sidebar__theme-toggle"
@@ -81,7 +81,7 @@ function SidebarBrand({
{theme === 'dark' ? <ThemeIconSun /> : <ThemeIconMoon />}
</button>
</div>
<div className="app-sidebar__app-tag">{tag}</div>
{tag ? <div className="app-sidebar__app-tag">{tag}</div> : null}
</div>
</div>
)
@@ -96,6 +96,9 @@ const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [
{ id: 'wechat', label: '微信', hint: 'Chatbot 与扫码' },
]
/** 侧栏不展示「工作台」「微信」,路由仍保留供内部/深链使用 */
const SIDEBAR_NAV_ITEMS = NAV_ITEMS.filter((n) => n.id !== 'desk' && n.id !== 'wechat')
type StatusVariant =
| 'loading'
| 'online'
@@ -207,17 +210,17 @@ export default function App() {
useEffect(() => {
const h = window.appHost
if (!h?.onNavigate) return
return h.onNavigate((nav) => {
if (
nav === 'desk' ||
nav === 'record' ||
nav === 'network' ||
nav === 'proxy' ||
nav === 'system' ||
nav === 'wechat'
) {
setNav(nav)
}
return h.onNavigate((raw) => {
const nav =
raw === 'desk' || raw === 'wechat'
? 'record'
: raw === 'record' ||
raw === 'network' ||
raw === 'proxy' ||
raw === 'system'
? raw
: null
if (nav) setNav(nav)
})
}, [])
@@ -684,7 +687,7 @@ export default function App() {
return (
<div className="app-frame app-frame--verge">
<aside className="app-sidebar" aria-label="主导航">
<SidebarBrand tag="本地控制台" theme={theme} applyTheme={applyTheme} />
<SidebarBrand tag="" theme={theme} applyTheme={applyTheme} />
</aside>
<div className="app-workspace">
<div className="app-workspace__inner app-workspace__inner--center">
@@ -708,9 +711,9 @@ export default function App() {
return (
<div className="app-frame app-frame--verge">
<aside className="app-sidebar" aria-label="主导航">
<SidebarBrand tag="懒人包 · 本地" theme={theme} applyTheme={applyTheme} />
<SidebarBrand tag="" theme={theme} applyTheme={applyTheme} />
<nav className="app-sidebar__nav">
{NAV_ITEMS.map((item) => (
{SIDEBAR_NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"

View File

@@ -5,43 +5,18 @@ type LoginItem = {
executableWillLaunchAtLogin?: boolean
}
type PathInfo = {
userData: string
logs: string
temp: string
backendRoot: string
backendConfig: string
}
type ProcessMetric = {
pid?: number
type?: string
name?: string
cpu?: { percentUsage?: number }
memory?: number
}
export default function AppHostControls() {
const host = typeof window !== 'undefined' ? window.appHost : undefined
const [login, setLogin] = useState<LoginItem | null>(null)
const [paths, setPaths] = useState<PathInfo | null>(null)
const [metrics, setMetrics] = useState<ProcessMetric[]>([])
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [badgeDraft, setBadgeDraft] = useState(0)
const load = useCallback(async () => {
if (!host?.getPaths) return
if (!host?.getLoginItemSettings) return
setErr(null)
try {
const [li, p, m] = await Promise.all([
host.getLoginItemSettings?.() ?? Promise.resolve(null),
host.getPaths(),
host.getMetrics?.() ?? Promise.resolve([]),
])
const li = await (host.getLoginItemSettings?.() ?? Promise.resolve(null))
setLogin((li as LoginItem) ?? null)
setPaths(p as PathInfo)
setMetrics(Array.isArray(m) ? (m as ProcessMetric[]) : [])
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
@@ -75,7 +50,7 @@ export default function AppHostControls() {
</h2>
<p className="card__desc">
Electron <code className="remote-code">app</code>
Electron <code className="remote-code">app</code>
</p>
{err && (
<p className="remote-error" role="alert">
@@ -133,93 +108,9 @@ export default function AppHostControls() {
</button>
</div>
<p className="app-host-hint">
Windows使 / /
Windows使 /
</p>
</div>
<div className="app-host-block">
<div className="app-host-block__title">Dock macOS / Linux</div>
<div className="app-host-badge-row">
<input
type="number"
min={0}
max={99}
className="app-host-badge-input"
value={badgeDraft}
onChange={(e) => setBadgeDraft(Number(e.target.value) || 0)}
aria-label="角标数字"
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => void host.setBadge?.(badgeDraft)}
>
</button>
<button type="button" className="btn btn-secondary" onClick={() => void host.setBadge?.(0)}>
</button>
</div>
<p className="app-host-hint">Windows Dock </p>
</div>
<div className="app-host-block">
<div className="app-host-block__title">便</div>
{paths && (
<ul className="app-host-paths">
<li>
<span className="app-host-paths__k"></span>
<code className="app-host-paths__v">{paths.userData}</code>
</li>
<li>
<span className="app-host-paths__k"></span>
<code className="app-host-paths__v">{paths.logs}</code>
</li>
<li>
<span className="app-host-paths__k"></span>
<code className="app-host-paths__v">{paths.backendRoot}</code>
</li>
<li>
<span className="app-host-paths__k">config</span>
<code className="app-host-paths__v">{paths.backendConfig}</code>
</li>
</ul>
)}
</div>
<div className="app-host-block app-host-block--wide">
<div className="app-host-block__title">Electron app.getAppMetrics</div>
{metrics.length > 0 ? (
<div className="metrics-table-wrap">
<table className="metrics-table app-host-metrics">
<thead>
<tr>
<th></th>
<th>PID</th>
<th></th>
<th>CPU</th>
<th></th>
</tr>
</thead>
<tbody>
{metrics.map((row, i) => (
<tr key={`${row.pid ?? i}-${row.type ?? ''}`}>
<td>{row.type ?? '—'}</td>
<td>{row.pid ?? '—'}</td>
<td>{row.name ?? '—'}</td>
<td>
{row.cpu?.percentUsage != null ? `${row.cpu.percentUsage.toFixed(1)}%` : '—'}
</td>
<td>{row.memory != null ? `${(row.memory / 1024 / 1024).toFixed(1)} MB` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="card__desc"></p>
)}
</div>
</div>
</section>
)

View File

@@ -68,7 +68,15 @@ function fmtMbps(v: number | null | undefined): string {
return `${v.toFixed(2)} Mbps`
}
function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
function SiteRow({
title,
site,
speed,
}: {
title: string
site: SiteInfo
speed: SpeedBlock
}) {
const ok = site.reachable
return (
<div className="net-site">
@@ -83,6 +91,18 @@ function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
<span className="net-v">
{site.latency_ms != null && site.latency_ms >= 0 ? `${site.latency_ms.toFixed(0)} ms` : '—'}
</span>
<span className="net-k"></span>
<span className="net-v">{fmtMbps(speed.download_mbps)}</span>
<span className="net-k"></span>
<span className="net-v">{fmtMbps(speed.upload_mbps)}</span>
{(speed.download_error || speed.upload_error) && (
<>
<span className="net-k"></span>
<span className="net-v">
{[speed.download_error, speed.upload_error].filter(Boolean).join(' · ')}
</span>
</>
)}
<span className="net-k"> IP</span>
<span className="net-v net-v--mono">{site.resolved_ip ?? '—'}</span>
<span className="net-k"></span>
@@ -141,7 +161,7 @@ export default function NetworkDashboard() {
</h2>
<p className="card__desc net-dash__lead">
Google线 0.6MB
Google 0.6MB
/ 256KB
</p>
</div>
@@ -158,75 +178,14 @@ export default function NetworkDashboard() {
<>
<div className="net-site-row">
<SiteRow title="Google海外参考" site={view.google} />
<SiteRow title="抖音(国内参考)" site={view.douyin} />
<SiteRow title="Google海外参考" site={view.google} speed={view.speed.overseas} />
<SiteRow title="抖音(国内参考)" site={view.douyin} speed={view.speed.domestic} />
</div>
<div className="net-routes">
<span className="net-routes__label">线线</span>
<span
className={`net-routes__pill ${
view.routes.different === true
? 'net-routes__pill--yes'
: view.routes.different === false
? 'net-routes__pill--no'
: 'net-routes__pill--unk'
}`}
>
{view.routes.different === true
? '是(解析国家不同)'
: view.routes.different === false
? '否或不确定'
: '未知'}
</span>
<p className="net-routes__note">{view.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(view.speed.overseas.download_mbps)}</span>
</div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(view.speed.overseas.upload_mbps)}</span>
</div>
{(view.speed.overseas.download_error || view.speed.overseas.upload_error) && (
<p className="net-speed__hint">
{[view.speed.overseas.download_error, view.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(view.speed.domestic.download_mbps)}</span>
</div>
<div className="net-metric">
<span className="net-metric__k"></span>
<span className="net-metric__v">{fmtMbps(view.speed.domestic.upload_mbps)}</span>
</div>
{(view.speed.domestic.download_error || view.speed.domestic.upload_error) && (
<p className="net-speed__hint">
{[view.speed.domestic.download_error, view.speed.domestic.upload_error]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
</div>
<p className="net-speed__foot">
Google / httpbin
POST
</p>
</div>
<p className="net-speed__foot net-dash__speed-foot">
Google / httpbin
POST
</p>
</>
</section>
)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import { initApiFromMain } from './api'
@@ -32,15 +32,12 @@ export default function RemoteBinding() {
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [selectedIp, setSelectedIp] = useState('')
const [manualHost, setManualHost] = useState('')
const [qrImg, setQrImg] = useState<string | null>(null)
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null)
const pickBindIp = useCallback((): string => {
if (!status?.candidates?.length) return manualHost.trim()
if (selectedIp.trim()) return selectedIp.trim()
return manualHost.trim()
}, [status?.candidates?.length, selectedIp, manualHost])
return selectedIp.trim()
}, [selectedIp])
const load = useCallback(async () => {
if (!rb?.getStatus) {
@@ -61,6 +58,32 @@ export default function RemoteBinding() {
void load()
}, [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(() => {
if (!status?.candidates?.length) return
setSelectedIp((prev) => {
@@ -97,23 +120,6 @@ export default function RemoteBinding() {
void QRCode.toDataURL(json, { margin: 2, width: 224 }).then(setQrImg).catch(() => setQrImg(null))
}, [bindPayload])
const onToggle = async (enabled: boolean) => {
if (!rb?.setRemoteEnabled) return
setBusy(true)
setMsg('')
try {
const r = await rb.setRemoteEnabled(enabled)
if (!r.ok) {
setMsg(r.error)
return
}
await initApiFromMain()
await load()
} finally {
setBusy(false)
}
}
const onGenerateToken = async () => {
if (!rb?.generateToken) return
setBusy(true)
@@ -134,7 +140,7 @@ export default function RemoteBinding() {
const onGenerateQr = async () => {
if (!rb?.getBindPayload) return
if (!status?.remoteEnabled) {
setMsg('请先勾选「允许局域网 / 虚拟网访问」并等待后端重启完成。')
setMsg('请等待局域网 / 虚拟网访问已开启并生效后再试。')
return
}
const ip = pickBindIp()
@@ -142,8 +148,8 @@ export default function RemoteBinding() {
const hasList = !!status.candidates?.length
setMsg(
hasList
? '请从上方下拉选择本机 IP或在「手动 IP」中填写手机能访问的地址。'
: '未检测到本机局域网 IP在下方「手动 IP」中输入例如 192.168.x.x 或与手机同网段地址、EasyTier 虚拟 IP或点「刷新 IP 列表」后重试。',
? '请从上方下拉选择本机可访问的 IP。'
: '未检测到本机局域网 IP点「刷新 IP 列表」或检查网卡与虚拟网后再试。',
)
return
}
@@ -184,25 +190,6 @@ export default function RemoteBinding() {
<code className="remote-code">easytier</code> 0.0.0.0 API
</p>
<div className="remote-et-status" role="status">
<span className={`remote-et-pill ${s?.easytierBundled ? 'on' : 'off'}`}>
{s?.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier'}
</span>
{s?.easytierBundled && (
<span className={`remote-et-pill ${s.easytierRunning ? 'on' : 'warn'}`}>
{s.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
</span>
)}
{!!s?.easytierNetworkName && (
<span className="remote-et-meta">{s.easytierNetworkName}</span>
)}
</div>
{s?.easytierBundled && s.easytierLastError && (
<p className="remote-warn" role="status">
EasyTier{s.easytierLastError}
</p>
)}
{status?.envHostOverride && (
<p className="remote-warn" role="status">
WEB2_HOST
@@ -215,10 +202,10 @@ export default function RemoteBinding() {
type="checkbox"
className="remote-toggle-input"
checked={!!status?.remoteEnabled}
disabled={busy || !!status?.envHostOverride}
onChange={(e) => void onToggle(e.target.checked)}
disabled
readOnly
/>
<span> / 访 0.0.0.0</span>
<span> / 访 0.0.0.0</span>
</label>
</div>
@@ -244,7 +231,7 @@ export default function RemoteBinding() {
>
{(status?.candidates?.length
? status.candidates
: [{ address: '', name: '(暂无候选,请用下方手动 IP 或点刷新)' }]
: [{ address: '', name: '(暂无候选,请点刷新 IP 列表' }]
).map((c) => (
<option key={c.address || 'none'} value={c.address} disabled={!c.address}>
{c.address ? `${c.address}${c.name}` : c.name}
@@ -256,22 +243,6 @@ export default function RemoteBinding() {
</button>
</div>
<div className="remote-row">
<label className="field-label" htmlFor="remoteManualIp">
IP
</label>
<input
id="remoteManualIp"
type="text"
className="select-process"
placeholder="例如 192.168.1.10、10.126.126.1"
value={manualHost}
onChange={(e) => setManualHost(e.target.value)}
disabled={busy}
autoComplete="off"
/>
</div>
<div className="remote-row">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void onGenerateQr()}>
@@ -280,7 +251,7 @@ export default function RemoteBinding() {
</div>
{!s?.remoteEnabled && (
<p className="remote-qr-hint">访 API </p>
<p className="remote-qr-hint">访访 API </p>
)}
{s?.remoteEnabled && bindPayload && qrImg && (
@@ -305,7 +276,7 @@ export default function RemoteBinding() {
{s?.remoteEnabled && !bindPayload && (
<p className="remote-qr-hint">
IP IP IP
IP IP
</p>
)}