From 882ea9268c5c3daf0dc14481480c6643c1eda4c2 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 20 May 2026 21:38:58 -0500 Subject: [PATCH] 's' --- config/URL_config.ini | 2 +- config/relay_pro/url_config.ch_1774436412.ini | 2 +- config/youtube_channels.json | 5 +- electron/src/main/appHost.ts | 116 +++- electron/src/preload/index.ts | 1 + electron/src/renderer/src/App.css | 514 ++++++++++++------ electron/src/renderer/src/App.tsx | 186 ++----- electron/src/renderer/src/AppHostControls.tsx | 14 +- .../src/renderer/src/WorkspaceDashboard.tsx | 258 +++++++-- electron/src/renderer/src/vite-env.d.ts | 5 + 10 files changed, 717 insertions(+), 386 deletions(-) diff --git a/config/URL_config.ini b/config/URL_config.ini index 8491383..f7d3944 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1 +1 @@ -https://live.douyin.com/11664523242 \ No newline at end of file +https://live.douyin.com/116645232423 \ No newline at end of file diff --git a/config/relay_pro/url_config.ch_1774436412.ini b/config/relay_pro/url_config.ch_1774436412.ini index 8491383..f7d3944 100644 --- a/config/relay_pro/url_config.ch_1774436412.ini +++ b/config/relay_pro/url_config.ch_1774436412.ini @@ -1 +1 @@ -https://live.douyin.com/11664523242 \ No newline at end of file +https://live.douyin.com/116645232423 \ No newline at end of file diff --git a/config/youtube_channels.json b/config/youtube_channels.json index 8ceece8..d9a019d 100644 --- a/config/youtube_channels.json +++ b/config/youtube_channels.json @@ -105,10 +105,7 @@ "id": "ch_1779328176", "name": "频道 17", "douyinUrl": "", - "youtubeKey": "qwr233232352354", - "youtubeKeys": [ - "qwr233232352354" - ] + "youtubeKey": "qwr233232352354" } ] } \ No newline at end of file diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts index 820e92d..7f1a5e9 100644 --- a/electron/src/main/appHost.ts +++ b/electron/src/main/appHost.ts @@ -2,14 +2,34 @@ * Electron app 模块:开机启动、跳转列表、关于面板、日志路径、角标、优雅重启、指标等。 * 文档:https://www.electronjs.org/docs/latest/api/app */ -import { app, BrowserWindow, ipcMain, shell } from 'electron' +import { app, BrowserWindow, ipcMain, shell, type JumpListCategory } from 'electron' import fs from 'fs' import path from 'path' const VALID_NAV = new Set(['desk', 'record', 'network', 'remote', 'system', 'wechat']) +const APP_USER_MODEL_ID = 'com.douyin.recorder.console' +const APP_PROTOCOL = 'd2yexeapp' + +function parseNavFromProtocolUrl(raw: string): string | null { + try { + const u = new URL(raw) + if (u.protocol !== `${APP_PROTOCOL}:`) return null + const host = decodeURIComponent(u.hostname || '').trim() + const pathId = decodeURIComponent(u.pathname.replace(/^\/+/, '') || '').trim() + const queryNav = (u.searchParams.get('nav') || '').trim() + const id = queryNav || pathId || host + if (id === 'proxy') return 'network' + if (VALID_NAV.has(id)) return id + } catch { + /* ignore */ + } + return null +} export function parseNavFromArgv(argv: string[]): string | null { for (const a of argv) { + const navFromUrl = parseNavFromProtocolUrl(a) + if (navFromUrl) return navFromUrl const m = /^--nav=(.+)$/.exec(a) if (!m) continue const id = m[1].trim() @@ -23,7 +43,7 @@ export function parseNavFromArgv(argv: string[]): string | null { export function setAppUserModelIdEarly(): void { if (process.platform !== 'win32') return try { - app.setAppUserModelId('com.douyin.recorder.console') + app.setAppUserModelId(APP_USER_MODEL_ID) } catch { /* ignore */ } @@ -85,12 +105,12 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void { if (process.platform === 'win32') { try { - app.setUserTasks([ + const appTasks = [ { program: process.execPath, arguments: '--nav=record', - title: '录制', - description: '打开录制与转播控制', + title: '直播控制台', + description: '打开直播控制与日志', iconPath: process.execPath, iconIndex: 0, }, @@ -98,11 +118,72 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void { program: process.execPath, arguments: '--nav=network', title: '网络', - description: '网络测速与录制专用代理', + description: '打开网络测速与代理设置', iconPath: process.execPath, iconIndex: 0, }, - ]) + { + program: process.execPath, + arguments: '--nav=system', + title: '系统状态', + description: '查看本机负载与运行状态', + iconPath: process.execPath, + iconIndex: 0, + }, + ] + app.setUserTasks(appTasks) + + const logsDir = app.getPath('logs') + const configDir = path.join(getBackendRoot(), 'config') + fs.mkdirSync(logsDir, { recursive: true }) + fs.mkdirSync(configDir, { recursive: true }) + const explorerExe = path.join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe') + const jumpList: JumpListCategory[] = [ + { + type: 'tasks', + items: [ + ...appTasks.map((t) => ({ + type: 'task' as const, + program: t.program, + args: t.arguments, + title: t.title, + description: t.description, + iconPath: t.iconPath, + iconIndex: t.iconIndex, + })), + { type: 'separator' as const }, + { + type: 'task', + program: explorerExe, + args: `"${logsDir}"`, + title: '打开日志目录', + description: '查看客户端与内部服务日志', + iconPath: process.execPath, + iconIndex: 0, + }, + { + type: 'task', + program: explorerExe, + args: `"${configDir}"`, + title: '打开配置目录', + description: '查看直播配置文件', + iconPath: process.execPath, + iconIndex: 0, + }, + ], + }, + ] + app.setJumpList(jumpList) + } catch { + /* ignore */ + } + + try { + if (process.defaultApp && process.argv.length >= 2) { + app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]) + } else { + app.setAsDefaultProtocolClient(APP_PROTOCOL) + } } catch { /* ignore */ } @@ -125,6 +206,9 @@ export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { chrome: process.versions.chrome, electron: process.versions.electron, node: process.versions.node, + protocol: APP_PROTOCOL, + protocolRegistered: + process.platform === 'win32' ? app.isDefaultProtocolClient(APP_PROTOCOL) : false, } }) @@ -200,6 +284,24 @@ export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { } }) + ipcMain.removeHandler('app:register-protocol') + ipcMain.handle('app:register-protocol', () => { + try { + const ok = + process.defaultApp && process.argv.length >= 2 + ? app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]) + : app.setAsDefaultProtocolClient(APP_PROTOCOL) + if (!ok) return { ok: false as const, error: '系统拒绝注册快捷入口' } + return { + ok: true as const, + registered: app.isDefaultProtocolClient(APP_PROTOCOL), + protocol: APP_PROTOCOL, + } + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : String(e) } + } + }) + ipcMain.removeHandler('app:open-path') ipcMain.handle('app:open-path', async (_evt, key: unknown) => { const br = getBackendRoot() diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index 86191d7..789fe23 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld('appHost', { relaunch: () => ipcRenderer.invoke('app:relaunch'), getMetrics: () => ipcRenderer.invoke('app:get-metrics'), showAbout: () => ipcRenderer.invoke('app:show-about'), + registerProtocol: () => ipcRenderer.invoke('app:register-protocol'), setBadge: (n: number) => ipcRenderer.invoke('app:set-badge', n), getPaths: () => ipcRenderer.invoke('app:get-paths'), openPath: (key: string) => ipcRenderer.invoke('app:open-path', key), diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 44b5f91..319d0d3 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -625,7 +625,8 @@ body.ready { } .app-body--record { - max-width: 920px; + width: 100%; + max-width: 1440px; margin: 0 auto; padding: 0; } @@ -1066,6 +1067,38 @@ body.ready { gap: 14px; } +.app-record-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 430px); + align-items: start; + gap: 16px; +} + +.app-record-layout__primary { + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.app-record-layout__log { + position: sticky; + top: 0; + max-height: calc(100vh - 116px); + overflow: hidden; +} + +@media (max-width: 1180px) { + .app-record-layout { + grid-template-columns: 1fr; + } + + .app-record-layout__log { + position: static; + max-height: none; + } +} + .card { padding: 18px 20px; background: var(--bg-card); @@ -1131,12 +1164,7 @@ body.ready { } .live-command__top { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - margin-bottom: 16px; + margin-bottom: 14px; } .live-command__eyebrow { @@ -1158,50 +1186,16 @@ body.ready { } .live-command__desc { - max-width: 680px; + max-width: 720px; margin: 8px 0 0; font-size: 13px; color: var(--text-muted); } -.live-command__badge { - display: inline-flex; - align-items: center; - min-height: 34px; - padding: 7px 12px; - border-radius: var(--radius-pill); - font-size: 12px; - font-weight: 750; - border: 1px solid var(--border-card); - background: var(--bg-card-muted); -} - -.live-command__badge--ok { - color: var(--success); - border-color: rgba(61, 214, 140, 0.35); - background: var(--success-soft); -} - -.live-command__badge--warn { - color: var(--warn); - border-color: rgba(246, 173, 85, 0.35); - background: var(--warn-soft); -} - -.live-command__quick-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: 8px; - max-width: min(100%, 520px); -} - .live-command__stats { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 16px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; } .live-command__stat { @@ -1233,137 +1227,32 @@ body.ready { white-space: nowrap; } -.live-pipeline { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); - align-items: center; - gap: 10px; -} - -.live-pipeline__step { - min-height: 70px; - padding: 13px 14px; - border-radius: 16px; - color: #fff; - box-shadow: var(--shadow-sm); -} - -.live-pipeline__step span { - display: block; - margin-bottom: 3px; - font-size: 11px; - font-weight: 650; - opacity: 0.78; -} - -.live-pipeline__step strong { - display: block; - font-size: 14px; - font-weight: 800; -} - -.live-pipeline__step--source { - background: linear-gradient(145deg, #0e8f6e, #0f5f57); -} - -.live-pipeline__step--encode { - background: linear-gradient(145deg, #2f6eea, #27447d); -} - -.live-pipeline__step--youtube { - background: linear-gradient(145deg, #f04438, #9f1f19); -} - -.live-pipeline__arrow { - color: var(--text-muted); - font-size: 28px; - line-height: 1; -} - -.live-preflight-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin-top: 14px; -} - -.live-preflight-item { - display: flex; - align-items: flex-start; - gap: 9px; - min-width: 0; - min-height: 62px; - padding: 10px 11px; - border-radius: var(--radius-md); - border: 1px solid var(--border-subtle); - background: rgba(255, 255, 255, 0.032); -} - -:root[data-theme="light"] .live-preflight-item { - background: rgba(15, 23, 42, 0.028); -} - -.live-preflight-item__dot { - width: 8px; - height: 8px; - margin-top: 6px; - border-radius: var(--radius-pill); - flex-shrink: 0; - background: var(--warn); - box-shadow: 0 0 0 4px var(--warn-soft); -} - -.live-preflight-item.is-ok .live-preflight-item__dot { - background: var(--success); - box-shadow: 0 0 0 4px var(--success-soft); -} - -.live-preflight-item__body { - min-width: 0; -} - -.live-preflight-item__body strong, -.live-preflight-item__body span { +.live-command__stat em { display: block; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.live-preflight-item__body strong { - font-size: 12px; - font-weight: 750; - color: var(--text); -} - -.live-preflight-item__body span { margin-top: 2px; font-size: 11px; color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; + font-style: normal; } @media (max-width: 860px) { .live-command__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .live-pipeline { - grid-template-columns: 1fr; - } - - .live-pipeline__arrow { - display: none; - } - - .live-preflight-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } .card--log .card__desc { margin-bottom: 12px; } +.card--log { + display: flex; + flex-direction: column; +} + .input-area { width: 100%; margin: 0 0 12px; @@ -2087,6 +1976,9 @@ body.ready { flex-direction: column; gap: 12px; font-size: 13px; + min-height: 0; + overflow: auto; + padding-right: 2px; } .live-log-detail__row { @@ -2183,7 +2075,7 @@ a.live-log-detail__link { font-family: var(--font-mono); font-size: 11px; line-height: 1.5; - max-height: 160px; + max-height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word; @@ -2192,6 +2084,10 @@ a.live-log-detail__link { border-radius: var(--radius-md); } +.app-record-layout__log .live-log-detail__pre { + max-height: 260px; +} + .live-log-detail__pre--err { color: #f0a8a8; border-color: rgba(240, 100, 100, 0.35); @@ -2547,6 +2443,8 @@ button.net-badge { } .app-panel--workspace { + width: 100%; + max-width: 1320px; display: flex; flex-direction: column; gap: 0; @@ -2554,10 +2452,18 @@ button.net-badge { .card--workspace { border-color: rgba(110, 168, 254, 0.25); - background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%); + background: + linear-gradient(165deg, rgba(110, 168, 254, 0.07) 0%, transparent 36%), + linear-gradient(180deg, var(--bg-card) 0%, rgba(25, 29, 38, 0.98) 100%); animation: cardWorkspaceIn 0.55s var(--ease) both; } +:root[data-theme="light"] .card--workspace { + background: + linear-gradient(165deg, rgba(37, 99, 235, 0.08) 0%, transparent 38%), + linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + @keyframes cardWorkspaceIn { from { opacity: 0; @@ -2636,6 +2542,294 @@ button.net-badge { margin-bottom: 0.75rem; } +.ws-head__sub { + margin: 0.15rem 0 0; + font-size: 12px; + color: var(--text-muted); +} + +.ws-head__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.ws-status-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 6px 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 650; + border: 1px solid var(--border-card); + color: var(--text-muted); + background: var(--bg-card-muted); +} + +.ws-status-chip.is-on { + color: var(--success); + border-color: rgba(61, 214, 140, 0.35); + background: var(--success-soft); +} + +.ws-status-chip.is-idle { + color: var(--accent-2); + border-color: rgba(91, 157, 255, 0.28); + background: var(--accent-dim); +} + +.ws-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.ws-kpi-card { + min-width: 0; + padding: 14px 15px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.018) 100%), + rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-sm); +} + +:root[data-theme="light"] .ws-kpi-card { + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.025) 0%, rgba(15, 23, 42, 0.012) 100%), + #ffffff; +} + +.ws-kpi-card span, +.ws-kpi-card em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-kpi-card span { + font-size: 11px; + font-weight: 650; + color: var(--text-muted); +} + +.ws-kpi-card strong { + display: block; + margin-top: 3px; + font-size: 24px; + line-height: 1.1; + color: var(--text); +} + +.ws-kpi-card em { + margin-top: 5px; + font-size: 11px; + font-style: normal; + color: var(--text-muted); +} + +.ws-kpi-card.is-ok { + border-color: rgba(61, 214, 140, 0.22); +} + +.ws-kpi-card.is-warn { + border-color: rgba(246, 173, 85, 0.28); + background: + linear-gradient(180deg, rgba(246, 173, 85, 0.09) 0%, rgba(246, 173, 85, 0.025) 100%), + rgba(0, 0, 0, 0.18); +} + +.ws-ops-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.85fr); + gap: 12px; + margin-bottom: 1rem; +} + +.ws-panel { + min-width: 0; + padding: 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border-card); + background: rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-sm); +} + +:root[data-theme="light"] .ws-panel { + background: #ffffff; +} + +.ws-panel__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} + +.ws-panel__title { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--text); +} + +.ws-panel__desc { + margin: 3px 0 0; + font-size: 11px; + color: var(--text-muted); +} + +.ws-panel__badge { + flex-shrink: 0; + padding: 4px 8px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 650; + color: var(--accent-2); + background: var(--accent-dim); + border: 1px solid rgba(91, 157, 255, 0.22); +} + +.ws-channel-list { + display: grid; + gap: 8px; +} + +.ws-channel-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 9px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.025); +} + +:root[data-theme="light"] .ws-channel-row { + background: var(--bg-card-muted); +} + +.ws-channel-row__main { + min-width: 0; +} + +.ws-channel-row__main strong, +.ws-channel-row__main span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-channel-row__main strong { + font-size: 13px; + color: var(--text); +} + +.ws-channel-row__main span { + margin-top: 2px; + font-size: 11px; + color: var(--text-muted); +} + +.ws-channel-row__badges { + display: flex; + gap: 5px; +} + +.ws-mini-badge { + padding: 2px 6px; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 700; + color: var(--warn); + background: var(--warn-soft); + border: 1px solid rgba(246, 173, 85, 0.28); +} + +.ws-mini-badge.is-ok { + color: var(--success); + background: var(--success-soft); + border-color: rgba(61, 214, 140, 0.28); +} + +.ws-health-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 10px; +} + +.ws-health-item { + min-width: 0; + padding: 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.025); +} + +.ws-health-item.is-ok { + border-color: rgba(61, 214, 140, 0.24); +} + +.ws-health-item.is-warn { + border-color: rgba(246, 173, 85, 0.24); +} + +.ws-health-item.is-off { + border-color: rgba(245, 101, 101, 0.28); +} + +.ws-health-item span, +.ws-health-item strong, +.ws-health-item em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-health-item span { + font-size: 11px; + color: var(--text-muted); +} + +.ws-health-item strong { + margin-top: 2px; + font-size: 15px; + color: var(--text); +} + +.ws-health-item em { + margin-top: 2px; + font-size: 10px; + font-style: normal; + color: var(--text-muted); +} + +@media (max-width: 980px) { + .ws-kpi-grid, + .ws-ops-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .ws-kpi-grid, + .ws-ops-grid, + .ws-health-grid { + grid-template-columns: 1fr; + } +} + .ws-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 71ccc5f..3dbbe87 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1543,13 +1543,6 @@ export default function App() { return parseYoutubeStreamKeySuffix(`key = ${youtubeIniActiveKey(ytIniModel)}`) }, [isYoutube, multiChannelPro, highlightedChannelId, committedChannelKeys, proChannelEditors, ytIniModel]) - const openYoutubeStudio = useCallback(() => { - const suffix = keySuffixForUi || undefined - const ensure = window.deskBridge?.ensureYoutubeStudio - if (ensure) void ensure(suffix) - else window.open('https://studio.youtube.com/livestreaming', '_blank', 'noopener,noreferrer') - }, [keySuffixForUi]) - const relayPs = liveDetail.processStatus const isRelayProcessLive = relayPs === 'online' const pmBusy = loadingAction !== null @@ -1629,18 +1622,6 @@ export default function App() { const proKeyCount = proLiveRows.filter((r) => r.keyConfigured).length const proSourceCount = proLiveRows.reduce((sum, r) => sum + r.sourceCount, 0) const selectedProRow = proLiveRows.find((r) => r.channelId === highlightedChannelId) - const liveReady = multiChannelPro - ? proReadyCount > 0 - : singleKeyConfigured && singleSourceCount > 0 - const livePreflightText = multiChannelPro - ? proVisibleLineCount === 0 - ? '还没有 Pro 频道' - : `${proReadyCount}/${proVisibleLineCount} 条线路可启动` - : liveReady - ? '单频道预检通过' - : !singleKeyConfigured - ? '缺少 YouTube 串流密钥' - : '缺少源站直播地址' const gpuStatusText = gpuCaps === null ? '检测中' : gpuCaps.nvenc_available @@ -1648,6 +1629,13 @@ export default function App() { : gpuCaps.nvidia_detected ? '已检测 NVIDIA,NVENC 不可用' : '未检测到 NVIDIA/NVENC' + const gpuSummaryText = gpuCaps === null + ? '检测中' + : gpuCaps.nvenc_available + ? 'NVENC 可用' + : gpuCaps.nvidia_detected + ? 'NVENC 不可用' + : '未检测到 NVIDIA' const liveBusinessLabel = businessStatusLabel(liveDetail.businessStatus, liveDetail.businessNote) const ffmpegWorkerText = liveDetail.ffmpegPids?.length ? liveDetail.ffmpegPids.join(', ') @@ -1655,63 +1643,16 @@ export default function App() { ? '未检测到' : '—' const logHeartbeatText = formatRuntimeSeconds(liveDetail.logAgeSeconds) - const preflightItems = useMemo(() => { - if (!isYoutube) return [] - const items: { label: string; ok: boolean; detail: string }[] = [] - if (multiChannelPro) { - items.push({ - label: '可启动线路', - ok: proReadyCount > 0, - detail: proVisibleLineCount ? `${proReadyCount}/${proVisibleLineCount} 条预检通过` : '还没有可用线路', - }) - items.push({ - label: '当前线路密钥', - ok: !!selectedProRow?.keyConfigured, - detail: selectedProRow?.keyConfigured ? `key …${selectedProRow.keyDisplay}` : '缺少 YouTube 串流密钥', - }) - items.push({ - label: '当前线路源站', - ok: (selectedProRow?.sourceCount ?? 0) > 0, - detail: selectedProRow ? `${selectedProRow.sourceCount} 条地址` : '请先选择一条线路', - }) - } else { - items.push({ - label: '串流密钥', - ok: singleKeyConfigured, - detail: singleKeyConfigured ? `key …${keySuffixForUi || '已配置'}` : '请填写 YouTube Studio 串流密钥', - }) - items.push({ - label: '源站地址', - ok: singleSourceCount > 0, - detail: singleSourceCount > 0 ? `${singleSourceCount} 条地址` : '请填写至少一个源站直播间地址', - }) - } - items.push({ - label: '编码能力', - ok: gpuCaps !== null, - detail: gpuStatusText, - }) - items.push({ - label: '运行态', - ok: liveDetail.isPushing === true || liveDetail.processStatus !== 'errored', - detail: liveBusinessLabel, - }) - return items - }, [ - gpuCaps, - gpuStatusText, - isYoutube, - keySuffixForUi, - liveBusinessLabel, - liveDetail.isPushing, - liveDetail.processStatus, - multiChannelPro, - proReadyCount, - proVisibleLineCount, - selectedProRow, - singleKeyConfigured, - singleSourceCount, - ]) + const configSummaryText = multiChannelPro + ? proVisibleLineCount === 0 + ? '暂无频道' + : `${proReadyCount}/${proVisibleLineCount} 条可启动` + : singleKeyConfigured + ? `key …${keySuffixForUi || '已配置'}` + : '缺少 key' + const configSummarySubText = multiChannelPro + ? `${proKeyCount} 个 key · ${proSourceCount} 条源站` + : `${singleSourceCount} 条源站` if (bootError) { return ( @@ -1920,6 +1861,8 @@ export default function App() { {!emptyHint && (
+
+
{isYoutube && (
@@ -1934,103 +1877,36 @@ export default function App() { : '适合一个源站直播间推送到一个 YouTube 直播间;需要多路并发时打开右上角 Pro。'}

-
- - {livePreflightText} - - - - - {douyinHints[0] && ( - - )} -
- 工作模式 - {multiChannelPro ? '多频道 Pro' : '单频道'} -
-
- 运行状态 + 直播状态 {multiChannelPro ? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 直播中` : statusMeta.line} + {liveBusinessLabel}
- 密钥 - - {multiChannelPro - ? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置` - : singleKeyConfigured ? '已配置' : '未配置'} - -
-
- 源站 - {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`} + 配置 + {configSummaryText} + {configSummarySubText}
编码 - {gpuStatusText} + {gpuSummaryText} + 本机能力
{multiChannelPro && selectedProRow && (
当前频道 {selectedProRow.channelName} + {selectedProRow.sourceCount} 条源站
)}
- -
-
- 源站 - 直播间地址 -
-
-
- 本机采集 - FFmpeg / GPU -
-
-
- YouTube - RTMP 推流 -
-
- -
- {preflightItems.map((item) => ( -
- -
- {item.label} - {item.detail} -
-
- ))} -
)} @@ -2592,7 +2468,8 @@ export default function App() { )} -
+
+ +
)} diff --git a/electron/src/renderer/src/AppHostControls.tsx b/electron/src/renderer/src/AppHostControls.tsx index 02d0c24..0752ac1 100644 --- a/electron/src/renderer/src/AppHostControls.tsx +++ b/electron/src/renderer/src/AppHostControls.tsx @@ -20,6 +20,8 @@ type AppInfo = { chrome: string electron: string node: string + protocol: string + protocolRegistered: boolean } type AppPaths = { @@ -139,7 +141,7 @@ export default function AppHostControls() { 客户端与系统集成

- Windows 懒人包本体控制:开机启动、运行路径、日志目录、应用重启与 Electron 进程状态。 + Windows 懒人包本体控制:开机启动、任务栏快捷入口、运行路径、日志目录与 Electron 进程状态。

@@ -204,13 +206,21 @@ export default function AppHostControls() { > 记住配置目录 +

Electron {info?.electron || '—'} · Chrome {info?.chrome || '—'} · Node {info?.node || '—'} ·{' '} - {info?.platform || '—'} {info?.arch || ''} + {info?.platform || '—'} {info?.arch || ''} · {info?.protocol || 'd2yexeapp'}://{' '} + {info?.protocolRegistered ? '已注册' : '未确认'}

diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx index 71ac055..83efbe5 100644 --- a/electron/src/renderer/src/WorkspaceDashboard.tsx +++ b/electron/src/renderer/src/WorkspaceDashboard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { apiFetch, apiUrl } from './api' import { STORAGE_MULTI_CHANNEL_PRO } from './constants' import type { WorkspaceDbSnapshot } from './workspace' @@ -21,13 +21,6 @@ type ClientOverview = { relayPairingPolicy?: string relayValidationErrors?: string[] relayNotes?: string[] - wechat?: { - configured?: boolean - nickName?: string - wcId?: string - online?: boolean - proxyError?: string | null - } } type RelayLivePayload = { @@ -57,6 +50,56 @@ function fmtDur(sec: number | null | undefined): string { return `${r}s` } +function isFilledPreview(v: string | undefined): boolean { + const s = (v || '').trim() + if (!s) return false + return !/待填写|未配置|暂无|—/.test(s) +} + +function fmtNumber(n: number): string { + return Number.isFinite(n) ? String(n) : '0' +} + +function statusTone(ok: boolean | null | undefined): 'ok' | 'warn' | 'off' { + if (ok === true) return 'ok' + if (ok === false) return 'off' + return 'warn' +} + +function percentLabel(v: unknown): string { + return typeof v === 'number' && Number.isFinite(v) ? `${v.toFixed(1)}%` : '—' +} + +function networkSummary(data: unknown): { + googleReachable?: boolean + douyinReachable?: boolean + googleLatency: string + douyinLatency: string +} { + if (!data || typeof data !== 'object') { + return { googleLatency: '—', douyinLatency: '—' } + } + const d = data as Record + const g = d.google as { reachable?: boolean; latency_ms?: number } | undefined + const y = d.douyin as { reachable?: boolean; latency_ms?: number } | undefined + return { + googleReachable: g?.reachable, + douyinReachable: y?.reachable, + googleLatency: typeof g?.latency_ms === 'number' && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—', + douyinLatency: typeof y?.latency_ms === 'number' && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—', + } +} + +function systemSummary(data: unknown): { cpu: string; memory: string } { + if (!data || typeof data !== 'object') return { cpu: '—', memory: '—' } + const d = data as Record + const m = d.memory as { percent?: number } | undefined + return { + cpu: percentLabel(d.cpu_percent), + memory: percentLabel(m?.percent), + } +} + function SnapshotSummary({ data }: { data: unknown }) { if (!data || typeof data !== 'object') return
const d = data as Record @@ -182,6 +225,55 @@ export default function WorkspaceDashboard() { const net = data?.snapshots?.network_status const sys = data?.snapshots?.system_metrics + const channels = overview?.youtubeChannels ?? [] + const displayChannels = channels.filter((c) => isFilledPreview(c.keyPreview) || isFilledPreview(c.douyinPreview)) + const configuredChannels = channels.filter( + (c) => isFilledPreview(c.keyPreview) && isFilledPreview(c.douyinPreview), + ).length + const validationErrorCount = overview?.relayValidationErrors?.length ?? 0 + const recentLiveEvents = data?.liveEvents ?? [] + const recentUserActions = data?.userActions ?? [] + const recentClientLogs = data?.clientLogs ?? [] + const now = Date.now() + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + const liveEventsToday = recentLiveEvents.filter((r) => typeof r.ts === 'number' && r.ts >= todayStart.getTime()).length + const liveEventsHour = recentLiveEvents.filter((r) => typeof r.ts === 'number' && r.ts >= now - 60 * 60_000).length + const clientErrorCount = recentClientLogs.filter((r) => { + const level = String(r.level ?? '').toLowerCase() + const msg = String(r.message ?? '').toLowerCase() + return level.includes('error') || level.includes('warn') || msg.includes('error') || msg.includes('异常') + }).length + const liveRows = proLive?.relayRows ?? [] + const liveRowCount = liveRows.filter((r) => (r.durationSec ?? 0) > 0 || r.processOnline).length + const netSum = useMemo(() => networkSummary(net?.data), [net]) + const sysSum = useMemo(() => systemSummary(sys?.data), [sys]) + const healthItems = [ + { + label: 'Google', + value: netSum.googleReachable ? '可达' : netSum.googleReachable === false ? '不可达' : '待测速', + meta: netSum.googleLatency, + tone: statusTone(netSum.googleReachable), + }, + { + label: '源站', + value: netSum.douyinReachable ? '可达' : netSum.douyinReachable === false ? '不可达' : '待测速', + meta: netSum.douyinLatency, + tone: statusTone(netSum.douyinReachable), + }, + { + label: 'CPU', + value: sysSum.cpu, + meta: '本机负载', + tone: sysSum.cpu === '—' ? 'warn' : 'ok', + }, + { + label: '内存', + value: sysSum.memory, + meta: '工作余量', + tone: sysSum.memory === '—' ? 'warn' : 'ok', + }, + ] return (
@@ -196,12 +288,18 @@ export default function WorkspaceDashboard() {

- 仪表盘 + 直播运营仪表盘

+

线路、推流、网络和本机状态集中看板

+
+
+ + {proMode ? (proLive?.processOnline ? 'Pro 进程运行中' : 'Pro 进程未运行') : '单频道模式'} + +
-
{err && ( @@ -210,36 +308,83 @@ export default function WorkspaceDashboard() {

)} -
-
-
YouTube 频道配置
-
    -
  • - 频道数 - {overview?.youtubeChannelCount ?? 0} -
  • -
  • - 配对策略 - {overview?.relayPairingPolicy || '一源站对应一 YouTube key'} -
  • -
- {overview?.youtubeChannels?.length ? ( -
- {overview.youtubeChannels.slice(0, 6).map((c) => ( -

- {c.name} · key {c.keyPreview || '未配置'} · {c.douyinPreview || '未配置源站'} -

- ))} +
+
+ 频道配置 + {fmtNumber(configuredChannels)}/{fmtNumber(overview?.youtubeChannelCount ?? channels.length)} + {overview?.relayPairingPolicy || '一源站对应一 YouTube key'} +
+
+ 正在直播 + {proMode ? fmtNumber(liveRowCount) : fmtNumber(liveEventsHour)} + {proMode ? 'Pro 线路实况' : '近 1 小时事件'} +
+
+ 今日事件 + {fmtNumber(liveEventsToday)} + 直播与任务记录 +
+
+ 风险提示 + {fmtNumber(validationErrorCount + clientErrorCount)} + {validationErrorCount ? '配置需处理' : clientErrorCount ? '日志需关注' : '未发现明显风险'} +
+
+ +
+
+
+
+

频道线路

+

+ {displayChannels.length ? `显示 ${Math.min(displayChannels.length, 8)} 条已填写线路` : '暂无已填写线路'} +

+
+ {configuredChannels} 条完整 +
+ {displayChannels.length ? ( +
+ {displayChannels.slice(0, 8).map((c) => { + const keyOk = isFilledPreview(c.keyPreview) + const sourceOk = isFilledPreview(c.douyinPreview) + return ( +
+
+ {c.name || '未命名频道'} + {c.douyinPreview || '未配置源站'} +
+
+ key + 源站 +
+
+ ) + })}
) : ( -

暂无频道配置

+

暂无已填写 key 或源站的频道

)}
-
-
配置体检
- {overview?.relayValidationErrors?.length ? ( -
- {overview.relayValidationErrors.slice(0, 5).map((x) => ( + +
+
+
+

运行健康

+

网络出口与本机余量

+
+
+
+ {healthItems.map((item) => ( +
+ {item.label} + {item.value} + {item.meta} +
+ ))} +
+ {validationErrorCount ? ( +
+ {overview?.relayValidationErrors?.slice(0, 4).map((x) => (

{x}

))}
@@ -248,30 +393,12 @@ export default function WorkspaceDashboard() { )} {overview?.relayNotes?.length ? (
- {overview.relayNotes.slice(0, 4).map((x) => ( + {overview.relayNotes.slice(0, 3).map((x) => (

{x}

))}
) : null}
-
-
微信机器人
-
    -
  • - 配置 - {overview?.wechat?.configured ? '已配置' : '未配置'} -
  • -
  • - 状态 - {overview?.wechat?.online ? '在线' : '离线'} -
  • -
  • - 账号 - {overview?.wechat?.nickName || overview?.wechat?.wcId || '—'} -
  • -
- {overview?.wechat?.proxyError ?

{overview.wechat.proxyError}

: null} -
{proMode && ( @@ -344,6 +471,23 @@ export default function WorkspaceDashboard() {

暂无

)}
+
+
最近活动
+
    +
  • + 操作 + {recentUserActions.length} 条 +
  • +
  • + 日志 + {recentClientLogs.length} 条 +
  • +
  • + 异常 + {clientErrorCount} 条 +
  • +
+
diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts index d3711ac..f9d64b8 100644 --- a/electron/src/renderer/src/vite-env.d.ts +++ b/electron/src/renderer/src/vite-env.d.ts @@ -77,12 +77,17 @@ interface Window { chrome: string electron: string node: string + protocol: string + protocolRegistered: boolean }> getLoginItemSettings?: () => Promise> setLoginItemOpenAtLogin?: (v: boolean) => Promise<{ ok: true } | { ok: false; error: string }> relaunch?: () => Promise getMetrics?: () => Promise showAbout?: () => Promise + registerProtocol?: () => Promise< + { ok: true; registered: boolean; protocol: string } | { ok: false; error: string } + > setBadge?: (n: number) => Promise getPaths?: () => Promise<{ userData: string