339 lines
14 KiB
HTML
339 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>多进程录制控制台</title>
|
||
<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)}}
|
||
</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>
|
||
|
||
<!-- 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>
|
||
</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>
|
||
</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>
|
||
|
||
<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>
|
||
<h2>实时日志(自动每秒刷新)</h2>
|
||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||
</div>
|
||
<script>
|
||
/** 进程列表来自 GET /process_monitor(web.py 扫描根目录);PM2 名 = 脚本主文件名(无扩展名)。 */
|
||
let PROCESS_MONITOR = [];
|
||
|
||
function pm2NameFromScript(script) {
|
||
const base = String(script).split(/[/\\]/).pop() || '';
|
||
const i = base.lastIndexOf('.');
|
||
return i > 0 ? base.slice(0, i) : base;
|
||
}
|
||
|
||
function isYoutubeScript(script) {
|
||
if (!/\.py$/i.test(script)) return false;
|
||
var s = pm2NameFromScript(script).toLowerCase();
|
||
return s.startsWith('youtube') || s.startsWith('douyin_youtube');
|
||
}
|
||
function isTiktokScript(script) {
|
||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
|
||
}
|
||
function isObsScript(script) {
|
||
return pm2NameFromScript(script).startsWith('obs');
|
||
}
|
||
|
||
async function loadProcessMonitor() {
|
||
const res = await fetch('/process_monitor');
|
||
if (!res.ok) throw new Error(String(res.status));
|
||
const data = await res.json();
|
||
const entries = data.entries || [];
|
||
PROCESS_MONITOR = entries.map((e) => ({
|
||
script: e.script,
|
||
label: e.label || e.script,
|
||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||
}));
|
||
}
|
||
|
||
function getEntryByPm2(name) {
|
||
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
|
||
}
|
||
|
||
function fillProcessSelect() {
|
||
processSelect.innerHTML = '';
|
||
PROCESS_MONITOR.forEach((e) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = e.pm2;
|
||
opt.textContent = `${e.label}(${e.script})`;
|
||
processSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
const logEl = document.getElementById('pm2Log');
|
||
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');
|
||
const urlStatus = document.getElementById('urlStatus');
|
||
|
||
function refreshProcessScriptNote() {
|
||
const el = document.getElementById('processScriptNote');
|
||
const ent = getEntryByPm2(currentProcess);
|
||
el.textContent = ent ? `脚本: ${ent.script} · PM2 名: ${ent.pm2}` : '';
|
||
}
|
||
|
||
processSelect.addEventListener('change', () => {
|
||
currentProcess = processSelect.value;
|
||
refreshProcessScriptNote();
|
||
fetchStatus();
|
||
updateSectionsVisibility();
|
||
});
|
||
|
||
function updateSectionsVisibility() {
|
||
const ent = getEntryByPm2(currentProcess);
|
||
const s = ent ? ent.script : '';
|
||
const isYoutube = ent && isYoutubeScript(s);
|
||
const isTiktok = ent && isTiktokScript(s);
|
||
const isObs = ent && isObsScript(s);
|
||
|
||
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
|
||
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
|
||
obsSection.style.display = isObs ? 'block' : 'none';
|
||
|
||
if (isYoutube) loadYoutubeConfig();
|
||
if (isYoutube || isTiktok) loadUrlConfig();
|
||
}
|
||
|
||
// textarea 高度自适应
|
||
function autoResize(textarea) {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||
}
|
||
[youtubeConfig, urlConfig].forEach(ta => {
|
||
ta.addEventListener('input', () => autoResize(ta));
|
||
autoResize(ta);
|
||
});
|
||
|
||
// 通用加载/保存
|
||
async function loadConfig(endpoint, textarea, statusEl) {
|
||
statusEl.textContent = '加载中...';
|
||
try {
|
||
const res = await fetch(`${endpoint}?process=${currentProcess}`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
textarea.value = data.content || '';
|
||
autoResize(textarea);
|
||
statusEl.textContent = '加载成功';
|
||
} catch (err) {
|
||
statusEl.textContent = `加载失败: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
async function saveConfig(endpoint, textarea, statusEl, saveBtn) {
|
||
statusEl.textContent = '保存中...';
|
||
const content = textarea.value;
|
||
try {
|
||
const res = await fetch(`${endpoint}?process=${currentProcess}`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({content})
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
statusEl.textContent = data.message || '保存成功';
|
||
} catch (err) {
|
||
statusEl.textContent = `保存失败: ${err.message}`;
|
||
} finally {
|
||
if (saveBtn) saveBtn.classList.remove('loading');
|
||
}
|
||
}
|
||
|
||
// youtube.ini(仅 youtube)
|
||
function loadYoutubeConfig() { loadConfig('/get_config', youtubeConfig, youtubeStatus); }
|
||
function saveYoutubeConfig() {
|
||
saveYoutubeBtn.classList.add('loading');
|
||
saveConfig('/save_config', youtubeConfig, youtubeStatus, saveYoutubeBtn);
|
||
}
|
||
loadYoutubeBtn.onclick = loadYoutubeConfig;
|
||
saveYoutubeBtn.onclick = saveYoutubeConfig;
|
||
|
||
// URL_config.ini(youtube 和 tiktok 共享)
|
||
function loadUrlConfig() { loadConfig('/get_url_config', urlConfig, urlStatus); }
|
||
function saveUrlConfig() {
|
||
saveUrlBtn.classList.add('loading');
|
||
saveConfig('/save_url_config', urlConfig, urlStatus, saveUrlBtn);
|
||
}
|
||
loadUrlBtn.onclick = loadUrlConfig;
|
||
saveUrlBtn.onclick = saveUrlConfig;
|
||
|
||
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
|
||
|
||
function updateStatus(process_status){
|
||
let emoji = '⚪', text = '未知';
|
||
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
|
||
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
|
||
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'; }
|
||
statusIndicator.innerHTML = `${emoji} ${text}`;
|
||
}
|
||
|
||
async function pm2Action(action){
|
||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||
if(btn) btn.classList.add('loading');
|
||
const url = `/${action}?process=${currentProcess}`;
|
||
try{
|
||
const res = await fetch(url);
|
||
if(!res.ok) throw new Error('网络错误');
|
||
const data = await res.json();
|
||
if(action === 'status'){
|
||
updateStatus(data.process_status);
|
||
let logText = `【PM2 全状态表格】(${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`;
|
||
}else{
|
||
logText += `【实时输出日志】`;
|
||
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
|
||
logText += `\n${data.recent_log}`;
|
||
}else{
|
||
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
|
||
}
|
||
logText += `\n\n【实时错误日志】`;
|
||
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
|
||
logText += `\n${data.recent_error}`;
|
||
}else{
|
||
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
|
||
}
|
||
}
|
||
logEl.textContent = logText;
|
||
scrollLog();
|
||
}else{
|
||
const ent = getEntryByPm2(currentProcess);
|
||
const who = ent ? `${ent.script} (${ent.pm2})` : currentProcess;
|
||
logEl.textContent = `操作: ${action} → ${who}\n${data.output || '成功'}\n\n正在刷新状态...`;
|
||
scrollLog();
|
||
setTimeout(fetchStatus, 1500);
|
||
}
|
||
}catch(err){
|
||
logEl.textContent = `请求失败: ${err.message}`;
|
||
scrollLog();
|
||
updateStatus('unknown');
|
||
}finally{
|
||
if(btn) btn.classList.remove('loading');
|
||
}
|
||
}
|
||
|
||
async function fetchStatus(){ pm2Action('status'); }
|
||
|
||
// 按钮事件委托
|
||
document.getElementById('pm2Buttons').addEventListener('click', e=>{
|
||
const btn = e.target.closest('button');
|
||
if(!btn) return;
|
||
const action = btn.dataset.action;
|
||
if(action && action !== 'status') pm2Action(action);
|
||
});
|
||
|
||
(async function boot() {
|
||
try {
|
||
await loadProcessMonitor();
|
||
} catch (e) {
|
||
logEl.textContent = '无法加载 /process_monitor(请用本服务打开的页面访问,或检查 web.py / uvicorn 是否已启动)\n' + (e && e.message ? e.message : '');
|
||
statusIndicator.textContent = '❌ 未连接';
|
||
return;
|
||
}
|
||
if (!PROCESS_MONITOR.length) {
|
||
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / douyin_youtube*.py / obs 脚本,且含 web.py)';
|
||
return;
|
||
}
|
||
fillProcessSelect();
|
||
currentProcess = PROCESS_MONITOR[0].pm2;
|
||
processSelect.value = currentProcess;
|
||
refreshProcessScriptNote();
|
||
setInterval(fetchStatus, 1000);
|
||
fetchStatus();
|
||
updateSectionsVisibility();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|