This commit is contained in:
eric
2026-03-24 11:34:35 -05:00
parent cc552d49f0
commit e6eb1c075b
25 changed files with 11949 additions and 180 deletions

5
.gitignore vendored
View File

@@ -165,3 +165,8 @@ cython_debug/
#.idea/
backup_config/
# Electron 桌面壳
electron/node_modules/
electron/dist/
electron/out/

View File

@@ -0,0 +1 @@
{}

View File

@@ -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,主播: ɞ_大馋丫头的烤肠

View 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

File diff suppressed because it is too large Load Diff

83
electron/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

233
electron/src/main/index.ts Normal file
View 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()
})
}

View File

@@ -0,0 +1,5 @@
import { contextBridge } from 'electron'
contextBridge.exposeInMainWorld('recorderConsole', {
platform: process.platform,
})

View 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>

View 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;
}

View 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>
)
}

View 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}`
}

View File

@@ -0,0 +1,2 @@
/** 为 true 时:下拉只展示 youtube*.py */
export const UI_DROPDOWN_YOUTUBE_ONLY = true

View 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>,
)

View 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
View 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
View 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"
]
}

View 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"]
}

View File

@@ -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_monitorweb2.py 启动时扫描项目根目录自动识别);PM2 名 = 脚本主文件名(无扩展名)。 */
/** 进程列表仅来自 GET /process_monitorweb2.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.iniyoutube 和 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>

View File

@@ -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]

View File

@@ -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

View File

@@ -0,0 +1,3 @@
将 Windows 版 ffmpeg 解压后,把 ffmpeg.exe 放在本文件夹(或 bin 子文件夹)中。
正式安装包打包时electron-builder 会把整个项目根目录(排除 electron 等)拷入安装目录,
此处文件会随软件分发给用户,用户无需单独安装 ffmpeg。

168
web2.py
View File

@@ -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)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 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
View File

@@ -0,0 +1,320 @@
"""
进程编排:默认使用内置 SupervisorWindows / 免 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,
}