's'
This commit is contained in:
20
electron/electron.vite.config.ts
Normal file
20
electron/electron.vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
},
|
||||
})
|
||||
5278
electron/package-lock.json
generated
Normal file
5278
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
electron/package.json
Normal file
83
electron/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "live-recorder-console",
|
||||
"productName": "直播录制助手",
|
||||
"version": "1.0.0",
|
||||
"description": "直播录制助手(Windows 懒人包)",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"dist": "electron-vite build && electron-builder --win --publish never"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-builder",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.douyin.recorder.console",
|
||||
"productName": "直播录制控制台",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "..",
|
||||
"to": "backend",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!electron/**",
|
||||
"!.git/**",
|
||||
"!**/__pycache__/**",
|
||||
"!**/.venv/**",
|
||||
"!**/venv/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"installerLanguages": [
|
||||
"zh_CN",
|
||||
"en_US"
|
||||
],
|
||||
"language": "2052"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
|
||||
}
|
||||
3972
electron/pnpm-lock.yaml
generated
Normal file
3972
electron/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
233
electron/src/main/index.ts
Normal file
233
electron/src/main/index.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const PORT = Number(process.env.WEB2_PORT || 8001)
|
||||
const HOST = '127.0.0.1'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let pythonChild: ChildProcessWithoutNullStreams | null = null
|
||||
let stderrBuf = ''
|
||||
|
||||
function getBackendRoot(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'backend')
|
||||
}
|
||||
// dev: electron/out/main -> 仓库根(上三级)
|
||||
return path.resolve(__dirname, '../../..')
|
||||
}
|
||||
|
||||
function resolvePythonExe(backendRoot: string): string {
|
||||
if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) {
|
||||
return process.env.PYTHON_EXE
|
||||
}
|
||||
const embedded = path.join(backendRoot, 'python', 'python.exe')
|
||||
if (fs.existsSync(embedded)) return embedded
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
function stderrTail(): string {
|
||||
const s = stderrBuf.trim()
|
||||
if (!s) {
|
||||
return '若您使用的是完整安装包仍无法启动,请尝试重启电脑后再次打开本软件,或联系技术支持获取帮助。'
|
||||
}
|
||||
return s.slice(-4000)
|
||||
}
|
||||
|
||||
/** 懒人包:把自带的 ffmpeg / 便携 Python 放进 PATH,用户无需自行安装 */
|
||||
function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env } as NodeJS.ProcessEnv
|
||||
const sep = path.delimiter
|
||||
const prepend: string[] = []
|
||||
|
||||
const ffmpegDirs = [
|
||||
path.join(backendRoot, 'tools', 'ffmpeg'),
|
||||
path.join(backendRoot, 'tools', 'ffmpeg', 'bin'),
|
||||
]
|
||||
for (const dir of ffmpegDirs) {
|
||||
if (fs.existsSync(dir)) prepend.push(dir)
|
||||
}
|
||||
|
||||
const portablePython = path.join(backendRoot, 'python')
|
||||
if (fs.existsSync(portablePython)) {
|
||||
prepend.push(portablePython)
|
||||
const scripts = path.join(portablePython, 'Scripts')
|
||||
if (fs.existsSync(scripts)) prepend.push(scripts)
|
||||
env.PYTHONHOME = portablePython
|
||||
}
|
||||
|
||||
if (prepend.length && env.PATH) {
|
||||
env.PATH = `${prepend.join(sep)}${sep}${env.PATH}`
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function waitForHealth(timeoutMs = 120000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const tick = (): void => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(iv)
|
||||
reject(new Error(`本地服务在 ${timeoutMs / 1000} 秒内未就绪。\n\n${stderrTail()}`))
|
||||
return
|
||||
}
|
||||
const req = http.get(`http://${HOST}:${PORT}/health`, (res) => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearInterval(iv)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
req.on('error', () => req.destroy())
|
||||
}
|
||||
const iv = setInterval(tick, 400)
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
function startPythonBackend(backendRoot: string, pythonExe: string): void {
|
||||
stderrBuf = ''
|
||||
const env = {
|
||||
...buildChildEnv(backendRoot),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
WEB2_SUPERVISOR: 'builtin',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
}
|
||||
const args = ['-m', 'uvicorn', 'web2:app', '--host', HOST, '--port', String(PORT)]
|
||||
pythonChild = spawn(pythonExe, args, {
|
||||
cwd: backendRoot,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
pythonChild.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.stdout?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.on('error', (err: Error) => {
|
||||
stderrBuf += `\nspawn error: ${err.message}`
|
||||
})
|
||||
pythonChild.on('close', (code) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (code !== 0 && code !== null) {
|
||||
dialog.showErrorBox('直播录制助手', `软件内部服务已停止运行。\n\n${stderrTail()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function killPythonTree(): void {
|
||||
if (!pythonChild || pythonChild.killed) return
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/PID', String(pythonChild.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} else {
|
||||
pythonChild.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pythonChild = null
|
||||
}
|
||||
|
||||
function resolvePreload(): string {
|
||||
const base = path.join(__dirname, '../preload')
|
||||
const mjs = path.join(base, 'index.mjs')
|
||||
const js = path.join(base, 'index.js')
|
||||
if (fs.existsSync(mjs)) return mjs
|
||||
return js
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 860,
|
||||
minWidth: 640,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
backgroundColor: '#f0f0f0',
|
||||
title: '直播录制助手',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: resolvePreload(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
mainWindow.once('ready-to-show', () => mainWindow?.show())
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
mainWindow.loadURL(devUrl)
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const backendRoot = getBackendRoot()
|
||||
const pythonExe = resolvePythonExe(backendRoot)
|
||||
|
||||
if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`未找到程序必要文件,当前安装可能不完整。\n\n路径:${backendRoot}\n\n开发调试:请在项目根目录保留 web2.py,并在 electron 目录执行 pnpm dev。`,
|
||||
)
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
startPythonBackend(backendRoot, pythonExe)
|
||||
|
||||
try {
|
||||
await waitForHealth()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
killPythonTree()
|
||||
})
|
||||
}
|
||||
5
electron/src/preload/index.ts
Normal file
5
electron/src/preload/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { contextBridge } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('recorderConsole', {
|
||||
platform: process.platform,
|
||||
})
|
||||
13
electron/src/renderer/index.html
Normal file
13
electron/src/renderer/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>直播录制助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
796
electron/src/renderer/src/App.css
Normal file
796
electron/src/renderer/src/App.css
Normal file
@@ -0,0 +1,796 @@
|
||||
/* 直播录制助手 — 浅色客户端 + 精致层次(离线可用,无外链字体) */
|
||||
|
||||
:root {
|
||||
--font-ui: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
|
||||
--font-mono: "Cascadia Mono", "Consolas", ui-monospace, monospace;
|
||||
|
||||
--bg-app-0: #e8edf4;
|
||||
--bg-app-1: #f0f4f9;
|
||||
--bg-chrome: rgba(255, 255, 255, 0.92);
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-muted: #f8fafc;
|
||||
--bg-input: #fcfcfd;
|
||||
--bg-log-0: #2b2d31;
|
||||
--bg-log-1: #1a1b1e;
|
||||
--border-subtle: rgba(15, 23, 42, 0.08);
|
||||
--border-card: rgba(15, 23, 42, 0.06);
|
||||
--border-strong: rgba(15, 23, 42, 0.14);
|
||||
--text: #1a1d21;
|
||||
--text-muted: #5f6b7a;
|
||||
--text-on-dark: #e4e6eb;
|
||||
|
||||
--accent: #0067c0;
|
||||
--accent-2: #0f6cbd;
|
||||
--accent-soft: rgba(0, 103, 192, 0.1);
|
||||
--accent-glow: rgba(0, 103, 192, 0.22);
|
||||
--accent-hover: #005a9e;
|
||||
--success: #0e7a0d;
|
||||
--success-soft: rgba(14, 122, 13, 0.12);
|
||||
--danger: #c42b1c;
|
||||
--danger-soft: rgba(196, 43, 28, 0.1);
|
||||
--warn: #c35a12;
|
||||
--warn-soft: rgba(195, 90, 18, 0.12);
|
||||
|
||||
--radius: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 4px 14px rgba(15, 23, 42, 0.07), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.1), 0 2px 8px rgba(15, 23, 42, 0.05);
|
||||
--shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background-color: var(--bg-app-1);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(0, 103, 192, 0.09), transparent 55%),
|
||||
linear-gradient(165deg, var(--bg-app-0) 0%, var(--bg-app-1) 45%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
body.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 根布局 */
|
||||
.app-frame {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-chrome {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 24px 18px;
|
||||
background: var(--bg-chrome);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-sm), 0 8px 32px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.app-chrome::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #0f6cbd 0%, #4a9fe8 48%, #7c5cff 100%);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.app-chrome__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.app-chrome__subtitle {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex: 1;
|
||||
padding: 20px 24px 32px;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.app-body--padded {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.app-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.app-toolbar__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: min(100%, 300px);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.select-process {
|
||||
appearance: none;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 40px 10px 14px;
|
||||
min-width: 240px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
|
||||
background-color: #f8fafc;
|
||||
background-image:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235f6b7a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"),
|
||||
linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-position: right 12px center, 0 0;
|
||||
background-size: 16px 16px, 100% 100%;
|
||||
transition:
|
||||
border-color 0.18s var(--ease),
|
||||
box-shadow 0.2s var(--ease),
|
||||
transform 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.select-process:hover:not(:disabled) {
|
||||
border-color: rgba(0, 103, 192, 0.45);
|
||||
box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow);
|
||||
}
|
||||
|
||||
.select-process:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-inset-top), 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.select-process:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 状态胶囊 */
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-card);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%);
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.status-pill__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='loading'] .status-pill__dot {
|
||||
animation: dotPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill[data-variant='online'] .status-pill__dot {
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 12px rgba(14, 122, 13, 0.55);
|
||||
animation: dotGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dotGlow {
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 16px rgba(14, 122, 13, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill[data-variant='loading'] {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='online'] {
|
||||
border-color: rgba(14, 122, 13, 0.28);
|
||||
background: linear-gradient(180deg, #f4fbf4 0%, #e8f5e8 100%);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='stopped'],
|
||||
.status-pill[data-variant='offline'] {
|
||||
border-color: rgba(196, 43, 28, 0.22);
|
||||
background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='warn'] {
|
||||
border-color: rgba(195, 90, 18, 0.28);
|
||||
background: linear-gradient(180deg, #fffaf5 0%, #fff0e0 100%);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='error'] {
|
||||
border-color: rgba(196, 43, 28, 0.28);
|
||||
background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill[data-variant='unknown'],
|
||||
.status-pill[data-variant='empty'] {
|
||||
border-color: rgba(0, 103, 192, 0.2);
|
||||
background: linear-gradient(180deg, #f5f9ff 0%, #e8f2fc 100%);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
/* 任务信息条 */
|
||||
.task-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 14px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.98) 100%);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.task-meta__label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-meta__name {
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-meta__file {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* 骨架 */
|
||||
.skeleton-block {
|
||||
margin: 12px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
#e8ecf2 0%,
|
||||
#f4f6fa 40%,
|
||||
#e8ecf2 80%,
|
||||
#e8ecf2 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton--title {
|
||||
height: 22px;
|
||||
width: 42%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.skeleton--line {
|
||||
height: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton--line.short {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton,
|
||||
.status-pill[data-variant='loading'] .status-pill__dot,
|
||||
.status-pill[data-variant='online'] .status-pill__dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 32px 16px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #e8eef6 0%, #d4e4f7 45%, #c8daf0 100%);
|
||||
box-shadow:
|
||||
var(--shadow-inset-top),
|
||||
0 8px 24px rgba(0, 103, 192, 0.12);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state__icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed rgba(0, 103, 192, 0.25);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 错误卡片 */
|
||||
.error-card {
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 20px 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fffbfb 100%);
|
||||
border: 1px solid rgba(196, 43, 28, 0.22);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.error-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #e85d52 0%, #c42b1c 100%);
|
||||
box-shadow: 0 6px 18px rgba(196, 43, 28, 0.35);
|
||||
}
|
||||
|
||||
.error-card__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.error-card__body {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 主内容 */
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 18px 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.card__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card__title--inline {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.card__desc {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card__field {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card__field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card--accent {
|
||||
border-color: rgba(0, 103, 192, 0.18);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f3f8fd 100%);
|
||||
box-shadow: var(--shadow-md), 0 0 0 1px rgba(0, 103, 192, 0.04);
|
||||
}
|
||||
|
||||
.card--log .card__desc {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
width: 100%;
|
||||
margin: 0 0 12px;
|
||||
padding: 12px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
resize: vertical;
|
||||
min-height: 108px;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition:
|
||||
border-color 0.18s var(--ease),
|
||||
box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.input-area:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(15, 23, 42, 0.04),
|
||||
0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background 0.15s var(--ease),
|
||||
border-color 0.15s var(--ease),
|
||||
box-shadow 0.2s var(--ease),
|
||||
transform 0.12s var(--ease);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f5f8 100%);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #eceff4 100%);
|
||||
border-color: rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(180deg, #0f6cbd 0%, #0067c0 55%, #005a9e 100%);
|
||||
color: #fff;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
box-shadow:
|
||||
var(--shadow-inset-top),
|
||||
0 4px 12px rgba(0, 103, 192, 0.28);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(180deg, #1a7ad4 0%, #0f6cbd 55%, #0067c0 100%);
|
||||
box-shadow:
|
||||
var(--shadow-inset-top),
|
||||
0 6px 18px rgba(0, 103, 192, 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.btn-secondary:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn.loading {
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hint-line {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card-muted);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mono-block {
|
||||
margin: 10px 0;
|
||||
padding: 12px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--accent-2);
|
||||
background: linear-gradient(135deg, #f5f9ff 0%, #e8f2fc 100%);
|
||||
border: 1px dashed rgba(0, 103, 192, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
word-break: break-all;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 录制按钮区 */
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.control-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
position: relative;
|
||||
padding: 13px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.12s var(--ease),
|
||||
filter 0.15s var(--ease),
|
||||
box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.ctrl-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, transparent 52%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
filter: brightness(1.04);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.ctrl-btn:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.ctrl-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ctrl-btn--start {
|
||||
background: linear-gradient(165deg, #2fb84a 0%, #0e7a0d 45%, #0a5c0a 100%);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: var(--shadow-inset-top), 0 4px 14px rgba(14, 122, 13, 0.28);
|
||||
}
|
||||
|
||||
.ctrl-btn--stop {
|
||||
background: linear-gradient(165deg, #f07167 0%, #d1342c 45%, #a7281c 100%);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-inset-top), 0 4px 14px rgba(196, 43, 28, 0.28);
|
||||
}
|
||||
|
||||
.ctrl-btn--restart {
|
||||
background: linear-gradient(165deg, #f5a742 0%, #e68600 45%, #c35a12 100%);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-inset-top), 0 4px 14px rgba(195, 90, 18, 0.28);
|
||||
}
|
||||
|
||||
.ctrl-btn--status {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f1f4f8 100%);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-inset-top), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ctrl-btn--status::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ctrl-btn--status:hover {
|
||||
filter: none;
|
||||
border-color: rgba(0, 103, 192, 0.45);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #e8f2fc 100%);
|
||||
box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow);
|
||||
}
|
||||
|
||||
.ctrl-btn.loading {
|
||||
opacity: 0.68;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 运行记录 */
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.live-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-2);
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(180deg, #e8f2fc 0%, #dceaf8 100%);
|
||||
border: 1px solid rgba(0, 103, 192, 0.18);
|
||||
box-shadow: var(--shadow-inset-top);
|
||||
}
|
||||
|
||||
.log-wrap {
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||
0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin: 0;
|
||||
height: min(380px, 50vh);
|
||||
min-height: 220px;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #d0d4dc;
|
||||
background: linear-gradient(180deg, var(--bg-log-0) 0%, var(--bg-log-1) 100%);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.log-panel--muted {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f3f5f8 100%);
|
||||
border: 1px solid var(--border-card);
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
text-shadow: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.log-panel::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.log-panel::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.log-panel::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #5a5d66 0%, #3d4048 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.log-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b6f7a;
|
||||
}
|
||||
480
electron/src/renderer/src/App.tsx
Normal file
480
electron/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { apiUrl } from './api'
|
||||
import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
|
||||
|
||||
type Entry = { script: string; label: string; pm2: string }
|
||||
|
||||
type StatusVariant =
|
||||
| 'loading'
|
||||
| 'online'
|
||||
| 'stopped'
|
||||
| 'error'
|
||||
| 'warn'
|
||||
| 'offline'
|
||||
| 'unknown'
|
||||
| 'empty'
|
||||
|
||||
/** 状态文案:避免技术术语,用「正在录制 / 已停止」等说法 */
|
||||
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' }
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [currentProcess, setCurrentProcess] = useState('')
|
||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||
line: '请稍候…',
|
||||
variant: 'loading',
|
||||
})
|
||||
const [logText, setLogText] = useState('正在读取运行信息…')
|
||||
const [bootError, setBootError] = useState<string | null>(null)
|
||||
const [emptyHint, setEmptyHint] = useState<string | null>(null)
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
|
||||
const [youtubeText, setYoutubeText] = useState('')
|
||||
const [urlText, setUrlText] = useState('')
|
||||
const [youtubeStatus, setYoutubeStatus] = useState('就绪')
|
||||
const [urlStatus, setUrlStatus] = useState('就绪')
|
||||
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null)
|
||||
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
|
||||
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
|
||||
|
||||
const logRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const scrollLog = useCallback(() => {
|
||||
const el = logRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollLog()
|
||||
}, [logText, scrollLog])
|
||||
|
||||
const getEntry = useCallback(
|
||||
(name: string) => entries.find((e) => e.pm2 === name) ?? null,
|
||||
[entries],
|
||||
)
|
||||
|
||||
const ent = useMemo(() => getEntry(currentProcess), [getEntry, currentProcess])
|
||||
const script = ent?.script ?? ''
|
||||
const isYoutube = ent ? isYoutubeScript(script) : false
|
||||
const isTiktok = ent ? isTiktokScript(script) : false
|
||||
const isObs = ent ? isObsScript(script) : false
|
||||
|
||||
const loadProcessMonitor = useCallback(async () => {
|
||||
const res = await fetch(apiUrl('/process_monitor'))
|
||||
if (!res.ok) throw new Error(String(res.status))
|
||||
const data = await res.json()
|
||||
const raw: Entry[] = (data.entries || []).map(
|
||||
(e: { script: string; label?: string; pm2?: string }) => ({
|
||||
script: e.script,
|
||||
label: e.label || e.script,
|
||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||||
}),
|
||||
)
|
||||
const filtered = UI_DROPDOWN_YOUTUBE_ONLY ? raw.filter((e) => isYoutubeScript(e.script)) : raw
|
||||
setEntries(filtered)
|
||||
return filtered
|
||||
}, [])
|
||||
|
||||
const loadConfig = useCallback(
|
||||
async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => {
|
||||
setSt('正在读取…')
|
||||
try {
|
||||
const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`)
|
||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||
const data = await res.json()
|
||||
setContent(data.content || '')
|
||||
setSt('已读到最新内容')
|
||||
} catch (err) {
|
||||
setSt(`读取失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
[currentProcess],
|
||||
)
|
||||
|
||||
const saveConfig = useCallback(
|
||||
async (
|
||||
endpoint: string,
|
||||
content: string,
|
||||
setSt: (s: string) => void,
|
||||
endLoading: () => void,
|
||||
) => {
|
||||
setSt('正在保存…')
|
||||
try {
|
||||
const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||
const data = await res.json()
|
||||
setSt(data.message || '已保存')
|
||||
} catch (err) {
|
||||
setSt(`未能保存:${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
endLoading()
|
||||
}
|
||||
},
|
||||
[currentProcess],
|
||||
)
|
||||
|
||||
const runPm2Action = useCallback(
|
||||
async (action: string) => {
|
||||
const isStatus = action === 'status'
|
||||
if (!isStatus) setLoadingAction(action)
|
||||
try {
|
||||
const res = await fetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`)
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = await res.json()
|
||||
|
||||
if (action === 'status') {
|
||||
const ps = data.process_status as string
|
||||
const { line, variant } = statusPresentation(ps)
|
||||
setStatusMeta({ line, variant })
|
||||
|
||||
let textOut = `── 状态一览 · ${new Date().toLocaleTimeString()} ──\n${data.raw_status}\n\n`
|
||||
if (ps === 'not_found') {
|
||||
const e = getEntry(currentProcess)
|
||||
const hint = e ? `「${e.label}」` : '当前任务'
|
||||
textOut += `提示:${hint} 还没有开始运行。请先点「开始录制」。\n`
|
||||
} else {
|
||||
const rl = data.recent_log as string
|
||||
const re = data.recent_error as string
|
||||
textOut += `【正常运行时的输出】\n`
|
||||
if (rl.includes('不存在') || rl.includes('无日志路径')) {
|
||||
textOut += `${rl}`
|
||||
} else {
|
||||
textOut += `${rl || '(暂无新内容)'}`
|
||||
}
|
||||
textOut += `\n\n【异常或报错信息】\n`
|
||||
if (re.includes('不存在') || re.includes('无日志路径')) {
|
||||
textOut += `${re}`
|
||||
} else {
|
||||
textOut += `${re || '(暂无报错)'}`
|
||||
}
|
||||
}
|
||||
setLogText(textOut)
|
||||
} else {
|
||||
const e = getEntry(currentProcess)
|
||||
const who = e ? e.label : '当前任务'
|
||||
const out =
|
||||
typeof data.output === 'string'
|
||||
? data.output
|
||||
: [data.pm2_output, data.message, data.note]
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
const actionLabel =
|
||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}\n\n正在刷新状态…`)
|
||||
setTimeout(() => void runPm2Action('status'), 1500)
|
||||
}
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}\n\n请检查软件是否完整安装,或稍后重试。`)
|
||||
setStatusMeta({ line: '状态未知', variant: 'unknown' })
|
||||
} finally {
|
||||
if (!isStatus) setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[currentProcess, getEntry],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('ready')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const filtered = await loadProcessMonitor()
|
||||
if (cancelled) return
|
||||
setInitialLoad(false)
|
||||
if (!filtered.length) {
|
||||
setEmptyHint(
|
||||
UI_DROPDOWN_YOUTUBE_ONLY
|
||||
? '没有发现 YouTube 录制任务。\n若您刚放入程序文件,请关闭软件后重新打开一次。'
|
||||
: '没有发现可用的录制任务。请确认软件安装目录完整。',
|
||||
)
|
||||
setStatusMeta({ line: '暂无可选任务', variant: 'empty' })
|
||||
return
|
||||
}
|
||||
setEmptyHint(null)
|
||||
setCurrentProcess(filtered[0].pm2)
|
||||
} catch (e) {
|
||||
setInitialLoad(false)
|
||||
setBootError(
|
||||
`软件内部的录制服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`,
|
||||
)
|
||||
setStatusMeta({ line: '未连接', variant: 'offline' })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [loadProcessMonitor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries.length || !currentProcess) return
|
||||
void runPm2Action('status')
|
||||
}, [entries.length, currentProcess, runPm2Action])
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries.length || !currentProcess) return
|
||||
const id = window.setInterval(() => {
|
||||
void runPm2Action('status')
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [entries.length, currentProcess, runPm2Action])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ent || !currentProcess) return
|
||||
if (isYoutube) void loadConfig('/get_config', setYoutubeText, setYoutubeStatus)
|
||||
if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus)
|
||||
}, [ent, currentProcess, isYoutube, isTiktok, loadConfig])
|
||||
|
||||
const onProcessChange = (v: string) => {
|
||||
setCurrentProcess(v)
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 className="error-card__title">无法启动</h2>
|
||||
<pre className="error-card__body">{bootError}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-frame">
|
||||
<header className="app-chrome">
|
||||
<div className="app-chrome__title">直播录制助手</div>
|
||||
<p className="app-chrome__subtitle">打开即可用,无需单独安装运行环境</p>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<div className="app-toolbar">
|
||||
<div className="app-toolbar__left">
|
||||
<label className="field-label" htmlFor="processSelect">
|
||||
当前录制
|
||||
</label>
|
||||
<select
|
||||
id="processSelect"
|
||||
className="select-process"
|
||||
aria-label="选择要管理的录制任务"
|
||||
disabled={initialLoad || !!emptyHint}
|
||||
value={currentProcess}
|
||||
onChange={(e) => onProcessChange(e.target.value)}
|
||||
>
|
||||
{entries.map((e) => (
|
||||
<option key={e.pm2} value={e.pm2}>
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</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 && (
|
||||
<div className="skeleton-block" aria-busy aria-label="加载中">
|
||||
<div className="skeleton skeleton--title" />
|
||||
<div className="skeleton skeleton--line" />
|
||||
<div className="skeleton skeleton--line short" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialLoad && ent && (
|
||||
<div className="task-meta">
|
||||
<span className="task-meta__label">当前任务</span>
|
||||
<span className="task-meta__name">{ent.label}</span>
|
||||
<span className="task-meta__file" title="安装目录内的文件名">
|
||||
{ent.script}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emptyHint && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state__icon" aria-hidden />
|
||||
<p className="empty-state__title">还没有可用的录制任务</p>
|
||||
<pre className="log-panel log-panel--muted">{emptyHint}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!emptyHint && !initialLoad && (
|
||||
<main className="app-main">
|
||||
{isYoutube && (
|
||||
<section className="card">
|
||||
<h2 className="card__title">YouTube 相关设置</h2>
|
||||
<p className="card__desc">一般不用改;需要时改完点「保存」即可。</p>
|
||||
<div className="card__field">
|
||||
<span className="card__field-label">设置内容</span>
|
||||
<textarea
|
||||
id="youtubeConfig"
|
||||
className="input-area"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={youtubeText}
|
||||
onChange={(e) => setYoutubeText(e.target.value)}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
id="loadYoutubeBtn"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void loadConfig('/get_config', setYoutubeText, setYoutubeStatus)}
|
||||
>
|
||||
重新读取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="saveYoutubeBtn"
|
||||
className={`btn btn-primary ${saveYoutubeLoading ? 'loading' : ''}`}
|
||||
onClick={() => {
|
||||
setSaveYoutubeLoading(true)
|
||||
void saveConfig('/save_config', youtubeText, setYoutubeStatus, () =>
|
||||
setSaveYoutubeLoading(false),
|
||||
)
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<pre className="hint-line" id="youtubeStatus">
|
||||
{youtubeStatus}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(isYoutube || isTiktok) && (
|
||||
<section className="card">
|
||||
<h2 className="card__title">直播地址列表</h2>
|
||||
<p className="card__desc">要录制的直播间地址写在这里,一行一个。</p>
|
||||
<div className="card__field">
|
||||
<span className="card__field-label">地址列表</span>
|
||||
<textarea
|
||||
id="urlConfig"
|
||||
className="input-area"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={urlText}
|
||||
onChange={(e) => setUrlText(e.target.value)}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
id="loadUrlBtn"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void loadConfig('/get_url_config', setUrlText, setUrlStatus)}
|
||||
>
|
||||
重新读取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="saveUrlBtn"
|
||||
className={`btn btn-primary ${saveUrlLoading ? 'loading' : ''}`}
|
||||
onClick={() => {
|
||||
setSaveUrlLoading(true)
|
||||
void saveConfig('/save_url_config', urlText, setUrlStatus, () =>
|
||||
setSaveUrlLoading(false),
|
||||
)
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<pre className="hint-line" id="urlStatus">
|
||||
{urlStatus}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!UI_DROPDOWN_YOUTUBE_ONLY && isObs && (
|
||||
<section className="card">
|
||||
<h2 className="card__title">OBS 直播软件</h2>
|
||||
<p className="card__desc">在 OBS 里选「自定义」推流,服务器填下面这一行:</p>
|
||||
<div id="obsAddress" className="mono-block">
|
||||
srt://live.local:10080/live/obs
|
||||
</div>
|
||||
<p className="card__desc">密码那一栏可以留空。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card card--accent" aria-busy={loadingAction !== null}>
|
||||
<h2 className="card__title">录制开关</h2>
|
||||
<div className="control-grid" id="pm2Buttons">
|
||||
{(
|
||||
[
|
||||
['start', '开始录制', '开始录制当前任务'],
|
||||
['stop', '停止录制', '停止当前录制'],
|
||||
['restart', '重新开始', '先停再开'],
|
||||
['status', '刷新状态', '马上看最新状态'],
|
||||
] as const
|
||||
).map(([action, label, title]) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
data-action={action}
|
||||
className={`ctrl-btn ctrl-btn--${action} ${loadingAction === action ? 'loading' : ''}`}
|
||||
title={title}
|
||||
onClick={() => void runPm2Action(action)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card card--log">
|
||||
<div className="log-head">
|
||||
<h2 className="card__title card__title--inline">运行记录</h2>
|
||||
<span className="live-tag">每秒自动更新</span>
|
||||
</div>
|
||||
<p className="card__desc">这里显示软件在做什么。出问题时可截图发给客服。</p>
|
||||
<div className="log-wrap">
|
||||
<pre id="pm2Log" ref={logRef} className="log-panel" aria-label="运行记录">
|
||||
{logText}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
electron/src/renderer/src/api.ts
Normal file
6
electron/src/renderer/src/api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const base = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001'
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base.replace(/\/$/, '')}${p}`
|
||||
}
|
||||
2
electron/src/renderer/src/constants.ts
Normal file
2
electron/src/renderer/src/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** 为 true 时:下拉只展示 youtube*.py */
|
||||
export const UI_DROPDOWN_YOUTUBE_ONLY = true
|
||||
10
electron/src/renderer/src/main.tsx
Normal file
10
electron/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
17
electron/src/renderer/src/scriptUtils.ts
Normal file
17
electron/src/renderer/src/scriptUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function pm2NameFromScript(script: string): string {
|
||||
const base = String(script).split(/[/\\]/).pop() || ''
|
||||
const i = base.lastIndexOf('.')
|
||||
return i > 0 ? base.slice(0, i) : base
|
||||
}
|
||||
|
||||
export function isYoutubeScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
|
||||
}
|
||||
|
||||
export function isTiktokScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
|
||||
}
|
||||
|
||||
export function isObsScript(script: string): boolean {
|
||||
return pm2NameFromScript(script).startsWith('obs')
|
||||
}
|
||||
15
electron/src/renderer/src/vite-env.d.ts
vendored
Normal file
15
electron/src/renderer/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
recorderConsole?: {
|
||||
platform: string
|
||||
}
|
||||
}
|
||||
20
electron/tsconfig.json
Normal file
20
electron/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"electron.vite.config.ts",
|
||||
"src/main/**/*.ts",
|
||||
"src/preload/**/*.ts",
|
||||
"src/renderer/**/*.ts",
|
||||
"src/renderer/**/*.tsx"
|
||||
]
|
||||
}
|
||||
12
electron/tsconfig.node.json
Normal file
12
electron/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["electron.vite.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user