's'
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -165,3 +165,8 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
backup_config/
|
||||
|
||||
# Electron 桌面壳
|
||||
electron/node_modules/
|
||||
electron/dist/
|
||||
electron/out/
|
||||
|
||||
1
.pm2/supervisor_state.json
Normal file
1
.pm2/supervisor_state.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -36,4 +36,4 @@ https://live.douyin.com/573735790640,主播: 小董卤串(餐饮)
|
||||
https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭)
|
||||
https://live.douyin.com/210443559964,主播: 小小诺
|
||||
https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊
|
||||
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠
|
||||
https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠
|
||||
|
||||
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"]
|
||||
}
|
||||
658
index.html
658
index.html
@@ -1,92 +1,583 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>多进程录制控制台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
|
||||
.container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
h3{margin:16px 0 8px;font-size:1.2em}
|
||||
input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace;overflow:hidden;resize:none;min-height:100px}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
|
||||
.configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
|
||||
.configStatus{color:#0f0;margin-top:8px;font-family:monospace;font-size:0.9em}
|
||||
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
:root {
|
||||
--font-sans: "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
--bg0: #070a12;
|
||||
--bg1: #0f1424;
|
||||
--surface: rgba(22, 28, 48, 0.72);
|
||||
--surface-solid: #161c30;
|
||||
--elevated: rgba(28, 36, 62, 0.85);
|
||||
--border: rgba(148, 163, 184, 0.12);
|
||||
--border-strong: rgba(148, 163, 184, 0.22);
|
||||
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
|
||||
--accent: #38bdf8;
|
||||
--accent-dim: rgba(56, 189, 248, 0.14);
|
||||
--accent-glow: rgba(56, 189, 248, 0.35);
|
||||
--violet: #a78bfa;
|
||||
--success: #4ade80;
|
||||
--warning: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius: 14px;
|
||||
--radius-lg: 20px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--border);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.55);
|
||||
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg0: #f0f4fc;
|
||||
--bg1: #e2e8f4;
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--surface-solid: #ffffff;
|
||||
--elevated: rgba(255, 255, 255, 0.94);
|
||||
--border: rgba(15, 23, 42, 0.08);
|
||||
--border-strong: rgba(15, 23, 42, 0.14);
|
||||
--text: #0f172a;
|
||||
--text-muted: #475569;
|
||||
--text-dim: #64748b;
|
||||
--accent-dim: rgba(14, 165, 233, 0.12);
|
||||
--shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 0 0 1px var(--border);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
#pm2Log {
|
||||
background: #0c1222 !important;
|
||||
color: #a5f3fc !important;
|
||||
border-color: rgba(56, 189, 248, 0.2) !important;
|
||||
}
|
||||
.configStatus { color: var(--success) !important; }
|
||||
textarea, #processSelect {
|
||||
background: #f8fafc !important;
|
||||
color: #0f172a !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: var(--bg0);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 80% at 100% -20%, rgba(56, 189, 248, 0.18), transparent 50%),
|
||||
radial-gradient(ellipse 80% 60% at -10% 110%, rgba(167, 139, 250, 0.15), transparent 45%),
|
||||
linear-gradient(165deg, var(--bg0) 0%, var(--bg1) 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: clamp(16px, 4vw, 40px);
|
||||
}
|
||||
|
||||
body.ready .shell {
|
||||
animation: shellIn 0.65s var(--ease) both;
|
||||
}
|
||||
|
||||
@keyframes shellIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body.ready .shell { animation: none; }
|
||||
* { transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell-inner {
|
||||
padding: clamp(20px, 4vw, 32px);
|
||||
}
|
||||
|
||||
/* 顶栏 */
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 4vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-title span.eyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.process-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.process-row label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#processSelect {
|
||||
appearance: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 36px 10px 14px;
|
||||
min-width: min(100%, 280px);
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
#processSelect:hover {
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
}
|
||||
|
||||
#processSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
#statusIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#processScriptNote {
|
||||
margin: 0 0 20px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
#processScriptNote { background: rgba(15, 23, 42, 0.04); }
|
||||
}
|
||||
|
||||
/* 区块 */
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 1.1em;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--violet));
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.panel:last-child { margin-bottom: 0; }
|
||||
|
||||
.configEditor h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin: 0 0 12px;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.configButtons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s var(--ease), filter 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.configButtons button {
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.configButtons button:hover {
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
#loadYoutubeBtn, #loadUrlBtn {
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
#saveYoutubeBtn, #saveUrlBtn {
|
||||
background: linear-gradient(135deg, #0ea5e9, #6366f1);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);
|
||||
}
|
||||
|
||||
#saveYoutubeBtn:hover, #saveUrlBtn:hover {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
button.loading {
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button.loading::after {
|
||||
content: " …";
|
||||
animation: pulse 1s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.configStatus {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--success);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#obsSection p {
|
||||
margin: 8px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#obsAddress {
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border: 1px dashed rgba(56, 189, 248, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 进程控制按钮 */
|
||||
#pm2Buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
#pm2Buttons {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
#pm2Buttons button {
|
||||
padding: 12px 10px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
#pm2Buttons [data-action="start"] {
|
||||
background: linear-gradient(180deg, #22c55e, #16a34a);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
#pm2Buttons [data-action="start"]:hover { filter: brightness(1.06); }
|
||||
|
||||
#pm2Buttons [data-action="stop"] {
|
||||
background: linear-gradient(180deg, #fb7185, #e11d48);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(251, 113, 133, 0.3);
|
||||
}
|
||||
#pm2Buttons [data-action="stop"]:hover { filter: brightness(1.06); }
|
||||
|
||||
#pm2Buttons [data-action="restart"] {
|
||||
background: linear-gradient(180deg, #fbbf24, #d97706);
|
||||
color: #1c1917;
|
||||
box-shadow: 0 4px 14px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
#pm2Buttons [data-action="restart"]:hover { filter: brightness(1.05); }
|
||||
|
||||
#pm2Buttons [data-action="status"] {
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
#pm2Buttons [data-action="status"]:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* 日志终端 */
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
#pm2Log {
|
||||
margin: 0;
|
||||
height: min(380px, 52vh);
|
||||
min-height: 220px;
|
||||
padding: 16px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: #7dd3fc;
|
||||
background: linear-gradient(180deg, #0a0f18 0%, #06090f 100%);
|
||||
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#pm2Log::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
#pm2Log::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
#pm2Log::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 189, 248, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
#pm2Log::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.hint-muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>
|
||||
进程:
|
||||
<select id="processSelect"></select>
|
||||
<span id="statusIndicator">⚪ 加载中...</span>
|
||||
</h1>
|
||||
<p id="processScriptNote" style="font-size:0.88em;margin:-12px 0 20px;opacity:0.88;font-family:ui-monospace,monospace"></p>
|
||||
<div class="shell">
|
||||
<div class="shell-inner">
|
||||
<header class="app-header">
|
||||
<h1 class="app-title">
|
||||
<span class="eyebrow">Live Recorder</span>
|
||||
录制控制台
|
||||
</h1>
|
||||
<div class="process-row">
|
||||
<label for="processSelect">当前进程</label>
|
||||
<select id="processSelect" aria-label="选择录制进程"></select>
|
||||
<span id="statusIndicator" role="status" aria-live="polite">⚪ 加载中...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- YouTube 专属配置(仅 youtube 时显示) -->
|
||||
<div id="youtubeOnlySection" style="display:none;">
|
||||
<h2>YouTube 配置编辑 (youtube.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>youtube.ini</h3>
|
||||
<textarea id="youtubeConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadYoutubeBtn">加载配置</button>
|
||||
<button id="saveYoutubeBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="youtubeStatus">就绪</pre>
|
||||
<p id="processScriptNote"></p>
|
||||
|
||||
<div id="youtubeOnlySection" style="display:none;" class="panel">
|
||||
<h2 class="section-title">YouTube 配置</h2>
|
||||
<div class="configEditor">
|
||||
<h3>youtube.ini</h3>
|
||||
<textarea id="youtubeConfig" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="configButtons">
|
||||
<button type="button" id="loadYoutubeBtn">加载配置</button>
|
||||
<button type="button" id="saveYoutubeBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="youtubeStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL_config.ini 共享配置(youtube 或 tiktok 时显示) -->
|
||||
<div id="urlConfigSection" style="display:none;">
|
||||
<h2>URL 配置编辑 (URL_config.ini)</h2>
|
||||
<div class="configEditor">
|
||||
<h3>URL_config.ini</h3>
|
||||
<textarea id="urlConfig"></textarea>
|
||||
<div class="configButtons">
|
||||
<button id="loadUrlBtn">加载配置</button>
|
||||
<button id="saveUrlBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="urlStatus">就绪</pre>
|
||||
<div id="urlConfigSection" style="display:none;" class="panel">
|
||||
<h2 class="section-title">URL 列表</h2>
|
||||
<div class="configEditor">
|
||||
<h3>URL_config.ini</h3>
|
||||
<textarea id="urlConfig" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="configButtons">
|
||||
<button type="button" id="loadUrlBtn">加载配置</button>
|
||||
<button type="button" id="saveUrlBtn">保存配置</button>
|
||||
</div>
|
||||
<pre class="configStatus" id="urlStatus">就绪</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OBS 推流地址区(仅 obs 时显示) -->
|
||||
<div id="obsSection" style="display:none;">
|
||||
<h2>OBS 直播推流地址</h2>
|
||||
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
|
||||
<div id="obsAddress">srt://live.local:10080/live/obs</div>
|
||||
<p>密钥(Stream Key)留空即可。</p>
|
||||
<div id="obsSection" style="display:none;" class="panel">
|
||||
<h2 class="section-title">OBS 推流</h2>
|
||||
<p>请在 OBS → 设置 → 推流 → 服务选择「自定义」,服务器填写以下地址:</p>
|
||||
<div id="obsAddress">srt://live.local:10080/live/obs</div>
|
||||
<p>密钥(Stream Key)留空即可。</p>
|
||||
</div>
|
||||
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
<div class="panel">
|
||||
<h2 class="section-title">进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button type="button" data-action="start">启动</button>
|
||||
<button type="button" data-action="stop">停止</button>
|
||||
<button type="button" data-action="restart">重启</button>
|
||||
<button type="button" data-action="status">手动刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
|
||||
<div class="panel">
|
||||
<div class="log-header">
|
||||
<h2 class="section-title" style="margin:0;">实时日志</h2>
|
||||
<span class="log-badge">自动刷新 · 1s</span>
|
||||
</div>
|
||||
<p class="hint-muted">输出与错误分流显示;状态每秒轮询。</p>
|
||||
<pre id="pm2Log" aria-label="运行日志">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
/** 进程列表仅来自 GET /process_monitor(web2.py 启动时扫描项目根目录自动识别);PM2 名 = 脚本主文件名(无扩展名)。 */
|
||||
/** 进程列表仅来自 GET /process_monitor(web2.py 启动时扫描项目根目录自动识别);进程名 = 脚本主文件名(无扩展名)。 */
|
||||
/** 完整列表(不删);下拉框仅用 YouTube 时用 PROCESS_MONITOR */
|
||||
let PROCESS_MONITOR_ALL = [];
|
||||
let PROCESS_MONITOR = [];
|
||||
|
||||
/** 为 true 时:下拉只展示 youtube*.py;其它入口仍存在于 PROCESS_MONITOR_ALL */
|
||||
const UI_DROPDOWN_YOUTUBE_ONLY = true;
|
||||
|
||||
function pm2NameFromScript(script) {
|
||||
const base = String(script).split(/[/\\]/).pop() || '';
|
||||
const i = base.lastIndexOf('.');
|
||||
@@ -108,11 +599,14 @@ async function loadProcessMonitor() {
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
const entries = data.entries || [];
|
||||
PROCESS_MONITOR = entries.map((e) => ({
|
||||
PROCESS_MONITOR_ALL = entries.map((e) => ({
|
||||
script: e.script,
|
||||
label: e.label || e.script,
|
||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||||
}));
|
||||
PROCESS_MONITOR = UI_DROPDOWN_YOUTUBE_ONLY
|
||||
? PROCESS_MONITOR_ALL.filter((e) => isYoutubeScript(e.script))
|
||||
: PROCESS_MONITOR_ALL;
|
||||
}
|
||||
|
||||
function getEntryByPm2(name) {
|
||||
@@ -134,18 +628,15 @@ const statusIndicator = document.getElementById('statusIndicator');
|
||||
const processSelect = document.getElementById('processSelect');
|
||||
let currentProcess = '';
|
||||
|
||||
// 各专属区域
|
||||
const youtubeOnlySection = document.getElementById('youtubeOnlySection');
|
||||
const urlConfigSection = document.getElementById('urlConfigSection');
|
||||
const obsSection = document.getElementById('obsSection');
|
||||
|
||||
// youtube.ini 编辑(youtube 专属)
|
||||
const youtubeConfig = document.getElementById('youtubeConfig');
|
||||
const loadYoutubeBtn = document.getElementById('loadYoutubeBtn');
|
||||
const saveYoutubeBtn = document.getElementById('saveYoutubeBtn');
|
||||
const youtubeStatus = document.getElementById('youtubeStatus');
|
||||
|
||||
// URL_config.ini 编辑(youtube 和 tiktok 共享)
|
||||
const urlConfig = document.getElementById('urlConfig');
|
||||
const loadUrlBtn = document.getElementById('loadUrlBtn');
|
||||
const saveUrlBtn = document.getElementById('saveUrlBtn');
|
||||
@@ -154,7 +645,8 @@ const urlStatus = document.getElementById('urlStatus');
|
||||
function refreshProcessScriptNote() {
|
||||
const el = document.getElementById('processScriptNote');
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
|
||||
el.textContent = ent ? `脚本: ${ent.script} · 进程名: ${ent.pm2}` : '';
|
||||
el.style.display = ent ? 'block' : 'none';
|
||||
}
|
||||
|
||||
processSelect.addEventListener('change', () => {
|
||||
@@ -173,13 +665,12 @@ function updateSectionsVisibility() {
|
||||
|
||||
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
|
||||
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
|
||||
obsSection.style.display = isObs ? 'block' : 'none';
|
||||
obsSection.style.display = UI_DROPDOWN_YOUTUBE_ONLY ? 'none' : (isObs ? 'block' : 'none');
|
||||
|
||||
if (isYoutube) loadYoutubeConfig();
|
||||
if (isYoutube || isTiktok) loadUrlConfig();
|
||||
}
|
||||
|
||||
// textarea 高度自适应
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||
@@ -189,7 +680,6 @@ function autoResize(textarea) {
|
||||
autoResize(ta);
|
||||
});
|
||||
|
||||
// 通用加载/保存
|
||||
async function loadConfig(endpoint, textarea, statusEl) {
|
||||
statusEl.textContent = '加载中...';
|
||||
try {
|
||||
@@ -223,7 +713,6 @@ async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
|
||||
}
|
||||
}
|
||||
|
||||
// youtube.ini(仅 youtube)
|
||||
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
|
||||
function saveYoutubeConfig() {
|
||||
saveYoutubeBtn.classList.add('loading');
|
||||
@@ -232,7 +721,6 @@ function saveYoutubeConfig() {
|
||||
loadYoutubeBtn.onclick = loadYoutubeConfig;
|
||||
saveYoutubeBtn.onclick = saveYoutubeConfig;
|
||||
|
||||
// URL_config.ini(youtube 和 tiktok 共享)
|
||||
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
|
||||
function saveUrlConfig() {
|
||||
saveUrlBtn.classList.add('loading');
|
||||
@@ -241,7 +729,7 @@ function saveUrlConfig() {
|
||||
loadUrlBtn.onclick = loadUrlConfig;
|
||||
saveUrlBtn.onclick = saveUrlConfig;
|
||||
|
||||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||||
function scrollLog(){ logEl.scrollTop = logEl.scrollHeight; }
|
||||
|
||||
function updateStatus(process_status){
|
||||
let emoji = '⚪', text = '未知';
|
||||
@@ -250,7 +738,7 @@ function updateStatus(process_status){
|
||||
else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
|
||||
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
|
||||
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
|
||||
else if(process_status === 'not_found'){ emoji = '❌'; text = '未启动'; }
|
||||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||||
}
|
||||
|
||||
@@ -264,11 +752,11 @@ async function pm2Action(action){
|
||||
const data = await res.json();
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
let logText = `【进程状态总览】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
if(data.process_status === 'not_found'){
|
||||
const ent = getEntryByPm2(currentProcess);
|
||||
const hint = ent ? `${ent.script}(PM2 名: ${ent.pm2})` : currentProcess;
|
||||
logText += `【提示】未在 PM2 中找到: ${hint}\n`;
|
||||
const hint = ent ? `${ent.script}(进程名: ${ent.pm2})` : currentProcess;
|
||||
logText += `【提示】未找到或未启动: ${hint}\n`;
|
||||
}else{
|
||||
logText += `【实时输出日志】`;
|
||||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||||
@@ -303,7 +791,6 @@ async function pm2Action(action){
|
||||
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
const btn = e.target.closest('button');
|
||||
if(!btn) return;
|
||||
@@ -317,10 +804,14 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
} catch (e) {
|
||||
logEl.textContent = '无法加载 /process_monitor(请用本服务打开的页面访问,或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
|
||||
statusIndicator.textContent = '❌ 未连接';
|
||||
document.body.classList.add('ready');
|
||||
return;
|
||||
}
|
||||
if (!PROCESS_MONITOR.length) {
|
||||
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh,且 Web 为当前 web*.py)';
|
||||
logEl.textContent = UI_DROPDOWN_YOUTUBE_ONLY
|
||||
? '未发现 youtube*.py 入口,下拉框为空。(服务端仍返回其它入口,见 PROCESS_MONITOR_ALL)'
|
||||
: '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh 或 obs*.bat,且 Web 为当前 web*.py)';
|
||||
document.body.classList.add('ready');
|
||||
return;
|
||||
}
|
||||
fillProcessSelect();
|
||||
@@ -330,6 +821,7 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||||
setInterval(fetchStatus, 1000);
|
||||
fetchStatus();
|
||||
updateSectionsVisibility();
|
||||
document.body.classList.add('ready');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -13,7 +13,10 @@ dependencies = [
|
||||
"distro>=1.9.0",
|
||||
"tqdm>=4.67.1",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"PyExecJS>=1.5.1"
|
||||
"PyExecJS>=1.5.1",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"psutil>=6.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -4,4 +4,7 @@ pycryptodome>=3.20.0
|
||||
distro>=1.9.0
|
||||
tqdm>=4.67.1
|
||||
httpx[http2]>=0.28.1
|
||||
PyExecJS>=1.5.1
|
||||
PyExecJS>=1.5.1
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
psutil>=6.0.0
|
||||
3
tools/ffmpeg/懒人包说明.txt
Normal file
3
tools/ffmpeg/懒人包说明.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
将 Windows 版 ffmpeg 解压后,把 ffmpeg.exe 放在本文件夹(或 bin 子文件夹)中。
|
||||
正式安装包打包时,electron-builder 会把整个项目根目录(排除 electron 等)拷入安装目录,
|
||||
此处文件会随软件分发给用户,用户无需单独安装 ffmpeg。
|
||||
168
web2.py
168
web2.py
@@ -2,10 +2,19 @@ from fastapi import FastAPI, Query, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from web2_supervisor import (
|
||||
BuiltinSupervisor,
|
||||
force_kill_ffmpeg_cross_platform,
|
||||
get_process_info_pm2,
|
||||
run_pm2_shell,
|
||||
strip_ansi_codes,
|
||||
use_builtin,
|
||||
)
|
||||
|
||||
app = FastAPI(title="多进程录制控制台")
|
||||
|
||||
# ---------------- 配置(根目录结构) ----------------
|
||||
@@ -21,7 +30,7 @@ def _label_short(prefix: str) -> str:
|
||||
|
||||
|
||||
def discover_script_entries() -> tuple:
|
||||
"""扫描项目根目录自动识别入口:tiktok*.py、youtube*.py、obs*.sh,以及当前本文件(Web 控制台)。PM2 名 = 主文件名(无扩展名)。"""
|
||||
"""扫描项目根目录自动识别入口:tiktok*.py、youtube*.py、obs*.sh / obs*.bat,以及当前本文件(Web 控制台)。进程名 = 主文件名(无扩展名)。"""
|
||||
base = BASE_DIR
|
||||
web_path = Path(__file__).resolve()
|
||||
entries: list[dict] = []
|
||||
@@ -38,6 +47,10 @@ def discover_script_entries() -> tuple:
|
||||
if path.is_file():
|
||||
entries.append({"script": path.name, "label": _label_short("obs")})
|
||||
|
||||
for path in sorted(base.glob("obs*.bat")):
|
||||
if path.is_file():
|
||||
entries.append({"script": path.name, "label": _label_short("obs")})
|
||||
|
||||
entries.append({"script": web_path.name, "label": _label_short("web")})
|
||||
|
||||
return tuple(entries)
|
||||
@@ -89,6 +102,48 @@ def script_for_pm2(pm2: str) -> Optional[str]:
|
||||
return p["script"]
|
||||
return None
|
||||
|
||||
|
||||
WEB_SCRIPT_NAME = Path(__file__).name
|
||||
_builtin_supervisor: Optional[BuiltinSupervisor] = None
|
||||
|
||||
|
||||
def get_builtin_supervisor() -> BuiltinSupervisor:
|
||||
global _builtin_supervisor
|
||||
if _builtin_supervisor is None:
|
||||
_builtin_supervisor = BuiltinSupervisor(BASE_DIR, WEB_SCRIPT_NAME, script_for_pm2)
|
||||
return _builtin_supervisor
|
||||
|
||||
|
||||
async def control_start(process: str) -> str:
|
||||
if use_builtin():
|
||||
return await get_builtin_supervisor().start(process)
|
||||
return await run_pm2_shell(f"start {process}")
|
||||
|
||||
|
||||
async def control_stop(process: str) -> str:
|
||||
if use_builtin():
|
||||
return await get_builtin_supervisor().stop(process)
|
||||
return await run_pm2_shell(f"stop {process}")
|
||||
|
||||
|
||||
async def control_restart(process: str) -> str:
|
||||
if use_builtin():
|
||||
return await get_builtin_supervisor().restart(process)
|
||||
return await run_pm2_shell(f"restart {process}")
|
||||
|
||||
|
||||
async def control_full_status() -> str:
|
||||
if use_builtin():
|
||||
return await get_builtin_supervisor().full_status_text()
|
||||
return await run_pm2_shell("status", timeout=8.0)
|
||||
|
||||
|
||||
async def control_get_process_info(process: str) -> dict:
|
||||
if use_builtin():
|
||||
return await get_builtin_supervisor().get_process_info(process)
|
||||
return await get_process_info_pm2(process, DEFAULT_LOG_DIR, strip_ansi_codes)
|
||||
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -98,50 +153,11 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# ---------------- 工具函数 ----------------
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
||||
|
||||
|
||||
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||
return result if result else "命令执行完成(无输出)"
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return "ERROR: PM2 命令超时,已强制终止"
|
||||
|
||||
|
||||
async def force_kill_ffmpeg(process_name: str):
|
||||
"""尝试强杀与该进程相关的 ffmpeg"""
|
||||
cmds = [
|
||||
# 优先杀带有进程名的 ffmpeg(最精准)
|
||||
f"pkill -f 'ffmpeg.*{process_name}' || true",
|
||||
# 再杀所有 ffmpeg(兜底,比较暴力)
|
||||
"killall -9 ffmpeg 2>/dev/null || true",
|
||||
]
|
||||
|
||||
for cmd in cmds:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
|
||||
|
||||
|
||||
async def read_log_path(path: str, lines: int) -> str:
|
||||
if not path:
|
||||
return "无日志路径\n"
|
||||
if "/dev/null" in path:
|
||||
return "日志输出被禁用(PM2 配置为无日志)\n"
|
||||
return "日志输出被禁用(无日志文件)\n"
|
||||
log_file = Path(path)
|
||||
if not log_file.exists():
|
||||
return f"日志文件不存在: {path}\n"
|
||||
@@ -153,47 +169,6 @@ async def read_log_path(path: str, lines: int) -> str:
|
||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||
|
||||
|
||||
async def get_process_info(process: str) -> dict:
|
||||
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
|
||||
describe_clean = strip_ansi_codes(describe_raw)
|
||||
|
||||
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_ansi_codes(line)
|
||||
if line_clean.count('│') < 2:
|
||||
continue
|
||||
parts = [p.strip() for p in line_clean.split('│') if p.strip()]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0].lower()
|
||||
value = parts[1]
|
||||
if key == "status":
|
||||
status = value.lower()
|
||||
elif key == "out_log_path":
|
||||
out_path = value
|
||||
elif key == "err_log_path":
|
||||
err_path = value
|
||||
|
||||
if not out_path:
|
||||
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
|
||||
# ---------------- 配置路由(youtube.ini,仅 youtube) ----------------
|
||||
@app.get("/get_config")
|
||||
async def get_config(process: str = Query(..., description="进程名")):
|
||||
@@ -269,7 +244,7 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
|
||||
async def start(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"start {process}")
|
||||
output = await control_start(process)
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@@ -279,22 +254,22 @@ async def stop(process: str = Query(..., description="进程名")):
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
|
||||
# 第一步:尝试优雅停止
|
||||
pm2_output = await run_pm2(f"stop {process}")
|
||||
ctrl_output = await control_stop(process)
|
||||
|
||||
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
|
||||
# 第二步:等待信号传播(通常 1~3 秒)
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
# 第三步:录制类进程才强杀 ffmpeg(web* 控制台不杀 ffmpeg)
|
||||
if not process.startswith("web"):
|
||||
await force_kill_ffmpeg(process)
|
||||
await force_kill_ffmpeg_cross_platform(process)
|
||||
|
||||
# 第四步:再等一小会儿,确认
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return JSONResponse({
|
||||
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
|
||||
"pm2_output": pm2_output,
|
||||
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
|
||||
"pm2_output": ctrl_output,
|
||||
"note": "如果仍有残留,可多次点击停止或查看下方日志",
|
||||
})
|
||||
|
||||
|
||||
@@ -302,9 +277,9 @@ async def stop(process: str = Query(..., description="进程名")):
|
||||
async def restart(process: str = Query(..., description="进程名")):
|
||||
if process not in VALID_PROCESSES:
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
output = await run_pm2(f"restart {process}")
|
||||
output = await control_restart(process)
|
||||
if not process.startswith("web"):
|
||||
await force_kill_ffmpeg(process)
|
||||
await force_kill_ffmpeg_cross_platform(process)
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
|
||||
@@ -318,14 +293,14 @@ async def status(process: str = Query(..., description="进程名")):
|
||||
"recent_error": ""
|
||||
})
|
||||
|
||||
full_status_raw = await run_pm2("status", timeout=8.0)
|
||||
full_status_raw = await control_full_status()
|
||||
full_status = strip_ansi_codes(full_status_raw)
|
||||
info = await get_process_info(process)
|
||||
info = await control_get_process_info(process)
|
||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||
|
||||
if info["process_status"] == "not_found":
|
||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||
recent_log = "进程未运行或未由本机管理器启动。\n"
|
||||
recent_error = ""
|
||||
|
||||
return JSONResponse({
|
||||
@@ -336,6 +311,11 @@ async def status(process: str = Query(..., description="进程名")):
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
|
||||
@app.get("/process_monitor")
|
||||
async def process_monitor():
|
||||
|
||||
320
web2_supervisor.py
Normal file
320
web2_supervisor.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
进程编排:默认使用内置 Supervisor(Windows / 免 PM2)。
|
||||
设置环境变量 WEB2_SUPERVISOR=pm2 时使用 PM2(与旧版 Linux 工作流兼容)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
def use_builtin() -> bool:
|
||||
return os.environ.get("WEB2_SUPERVISOR", "builtin").strip().lower() != "pm2"
|
||||
|
||||
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||
|
||||
|
||||
class BuiltinSupervisor:
|
||||
"""用本地 PID + 日志文件管理子进程,不依赖 Node/PM2。"""
|
||||
|
||||
def __init__(self, base_dir: Path, console_script: str, script_resolver: Callable[[str], Optional[str]]):
|
||||
self.base_dir = base_dir.resolve()
|
||||
self.console_script = console_script
|
||||
self.console_stem = Path(console_script).stem
|
||||
self._script_for = script_resolver
|
||||
self.log_dir = self.base_dir / ".pm2" / "logs"
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_path = self.base_dir / ".pm2" / "supervisor_state.json"
|
||||
self._io_lock = asyncio.Lock()
|
||||
|
||||
def _read_state(self) -> dict[str, Any]:
|
||||
if not self.state_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write_state(self, state: dict[str, Any]) -> None:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def _pid_alive(self, pid: int | None) -> bool:
|
||||
if pid is None or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
return psutil.Process(pid).is_running()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return False
|
||||
|
||||
def _log_paths(self, name: str) -> tuple[Path, Path]:
|
||||
out_p = self.log_dir / f"{name}-out.log"
|
||||
err_p = self.log_dir / f"{name}-error.log"
|
||||
return out_p, err_p
|
||||
|
||||
def _build_cmd(self, script: str) -> Optional[list[str]]:
|
||||
path = self.base_dir / script
|
||||
if not path.is_file():
|
||||
return None
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".py":
|
||||
return [sys.executable, str(path)]
|
||||
if ext == ".bat":
|
||||
comspec = os.environ.get("COMSPEC") or "cmd.exe"
|
||||
return [comspec, "/c", str(path)]
|
||||
if ext == ".sh":
|
||||
bash = shutil.which("bash") or shutil.which("sh")
|
||||
if not bash:
|
||||
return None
|
||||
return [bash, str(path)]
|
||||
return None
|
||||
|
||||
async def start(self, process_name: str) -> str:
|
||||
if process_name == self.console_stem:
|
||||
return "控制台已由本程序托管,无需单独启动。"
|
||||
|
||||
script = self._script_for(process_name)
|
||||
if not script:
|
||||
return f"未知进程名: {process_name}"
|
||||
|
||||
argv = self._build_cmd(script)
|
||||
if argv is None:
|
||||
path = self.base_dir / script
|
||||
if not path.is_file():
|
||||
return f"脚本不存在: {script}"
|
||||
if script.endswith(".sh"):
|
||||
return "未找到 bash/sh,无法运行 .sh(可改用 obs*.bat 或安装 Git for Windows)"
|
||||
return f"无法构建启动命令: {script}"
|
||||
|
||||
async with self._io_lock:
|
||||
state = self._read_state()
|
||||
entry = state.get(process_name, {})
|
||||
pid = entry.get("pid")
|
||||
if self._pid_alive(pid):
|
||||
return f"进程已在运行中(PID {pid})。"
|
||||
|
||||
out_p, err_p = self._log_paths(process_name)
|
||||
out_p.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_f = open(out_p, "ab")
|
||||
err_f = open(err_p, "ab")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
cwd=str(self.base_dir),
|
||||
stdout=out_f,
|
||||
stderr=err_f,
|
||||
creationflags=0,
|
||||
)
|
||||
except Exception as e:
|
||||
out_f.close()
|
||||
err_f.close()
|
||||
return f"启动失败: {e}"
|
||||
out_f.close()
|
||||
err_f.close()
|
||||
|
||||
pid = proc.pid
|
||||
state[process_name] = {
|
||||
"pid": pid,
|
||||
"script": script,
|
||||
"out_log": str(out_p),
|
||||
"err_log": str(err_p),
|
||||
}
|
||||
self._write_state(state)
|
||||
return f"已启动 {script}(PID {pid}),日志见 .pm2/logs"
|
||||
|
||||
async def stop(self, process_name: str) -> str:
|
||||
if process_name == self.console_stem:
|
||||
return "请关闭桌面应用窗口以结束控制台。"
|
||||
|
||||
async with self._io_lock:
|
||||
state = self._read_state()
|
||||
entry = state.get(process_name, {})
|
||||
pid = entry.get("pid")
|
||||
|
||||
if not self._pid_alive(pid):
|
||||
state.pop(process_name, None)
|
||||
self._write_state(state)
|
||||
return "进程未在运行(或已退出)。"
|
||||
|
||||
try:
|
||||
parent = psutil.Process(pid)
|
||||
children = parent.children(recursive=True)
|
||||
for c in children:
|
||||
try:
|
||||
c.terminate()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
try:
|
||||
parent.terminate()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
gone, alive = psutil.wait_procs([parent] + children, timeout=5)
|
||||
for p in alive:
|
||||
try:
|
||||
p.kill()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
state.pop(process_name, None)
|
||||
self._write_state(state)
|
||||
return f"已停止进程(原 PID {pid})。"
|
||||
|
||||
async def restart(self, process_name: str) -> str:
|
||||
if process_name == self.console_stem:
|
||||
return "控制台由桌面应用托管,请关闭窗口后重新打开应用。"
|
||||
out = await self.stop(process_name)
|
||||
await asyncio.sleep(0.5)
|
||||
out2 = await self.start(process_name)
|
||||
return f"{out}\n{out2}"
|
||||
|
||||
async def full_status_text(self) -> str:
|
||||
lines = [
|
||||
"┌──────────────────┬────────────┬────────────────────────────────────────┐",
|
||||
"│ 进程名 │ 状态 │ 说明 │",
|
||||
"├──────────────────┼────────────┼────────────────────────────────────────┤",
|
||||
]
|
||||
async with self._io_lock:
|
||||
state = self._read_state()
|
||||
lines.append(
|
||||
f"│ {self.console_stem:<16} │ {'online':<10} │ 控制台(本机 uvicorn) │"
|
||||
)
|
||||
for name, entry in sorted(state.items()):
|
||||
pid = entry.get("pid")
|
||||
alive = self._pid_alive(pid)
|
||||
st = "online" if alive else "stopped"
|
||||
scr = (entry.get("script") or "")[:38]
|
||||
lines.append(f"│ {name:<16} │ {st:<10} │ {scr:<38} │")
|
||||
lines.append(
|
||||
"└──────────────────┴────────────┴────────────────────────────────────────┘"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
async def get_process_info(self, process_name: str) -> dict[str, Any]:
|
||||
if process_name == self.console_stem:
|
||||
out_p, err_p = self._log_paths(process_name)
|
||||
return {
|
||||
"process_status": "online",
|
||||
"out_path": str(out_p),
|
||||
"err_path": str(err_p),
|
||||
}
|
||||
|
||||
async with self._io_lock:
|
||||
state = self._read_state()
|
||||
entry = state.get(process_name, {})
|
||||
pid = entry.get("pid")
|
||||
out_path = entry.get("out_log")
|
||||
err_path = entry.get("err_log")
|
||||
|
||||
if not out_path:
|
||||
out_path = str(self._log_paths(process_name)[0])
|
||||
if not err_path:
|
||||
err_path = str(self._log_paths(process_name)[1])
|
||||
|
||||
if not self._pid_alive(pid):
|
||||
return {
|
||||
"process_status": "stopped" if entry else "not_found",
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
|
||||
return {
|
||||
"process_status": "online",
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
|
||||
|
||||
async def run_pm2_shell(cmd: str, timeout: float = 15.0) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||
return result if result else "命令执行完成(无输出)"
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return "ERROR: PM2 命令超时,已强制终止"
|
||||
|
||||
|
||||
async def force_kill_ffmpeg_cross_platform(process_name: str) -> None:
|
||||
"""按命令行匹配业务进程名,结束相关 ffmpeg。"""
|
||||
|
||||
def _sync_kill() -> None:
|
||||
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
||||
try:
|
||||
name = (proc.info.get("name") or "").lower()
|
||||
if "ffmpeg" not in name:
|
||||
continue
|
||||
cmdline = proc.info.get("cmdline") or []
|
||||
flat = " ".join(str(x) for x in cmdline)
|
||||
if process_name and process_name not in flat:
|
||||
continue
|
||||
p = psutil.Process(proc.info["pid"])
|
||||
p.kill()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
await asyncio.to_thread(_sync_kill)
|
||||
await asyncio.sleep(0.35)
|
||||
|
||||
|
||||
async def get_process_info_pm2(
|
||||
process: str,
|
||||
default_log_dir: Path,
|
||||
strip_fn: Callable[[str], str],
|
||||
) -> dict[str, Any]:
|
||||
describe_raw = await run_pm2_shell(f"describe {process}", timeout=10.0)
|
||||
describe_clean = strip_fn(describe_raw)
|
||||
|
||||
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_fn(line)
|
||||
if line_clean.count("│") < 2:
|
||||
continue
|
||||
parts = [p.strip() for p in line_clean.split("│") if p.strip()]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0].lower()
|
||||
value = parts[1]
|
||||
if key == "status":
|
||||
status = value.lower()
|
||||
elif key == "out_log_path":
|
||||
out_path = value
|
||||
elif key == "err_log_path":
|
||||
err_path = value
|
||||
|
||||
if not out_path:
|
||||
out_path = str(default_log_dir / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(default_log_dir / f"{process}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
}
|
||||
Reference in New Issue
Block a user