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

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