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