This commit is contained in:
eric
2026-05-20 21:38:58 -05:00
parent d28e64f4ef
commit 882ea9268c
10 changed files with 717 additions and 386 deletions

View File

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

View File

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

View File

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

View File

@@ -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
? '已检测 NVIDIANVENC 不可用'
: '未检测到 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 && (
<main className="app-main">
<div className="app-record-layout">
<div className="app-record-layout__primary">
{isYoutube && (
<section className="card card--live-command">
<div className="live-command__top">
@@ -1934,103 +1877,36 @@ export default function App() {
: '适合一个源站直播间推送到一个 YouTube 直播间;需要多路并发时打开右上角 Pro。'}
</p>
</div>
<div className="live-command__quick-actions">
<span
className={`live-command__badge ${
liveReady ? 'live-command__badge--ok' : 'live-command__badge--warn'
}`}
>
{livePreflightText}
</span>
<button type="button" className="btn btn-secondary btn--sm" onClick={openYoutubeStudio}>
YouTube Studio
</button>
<button type="button" className="btn btn-secondary btn--sm" onClick={() => setNav('network')}>
</button>
<button type="button" className="btn btn-secondary btn--sm" onClick={() => setNav('system')}>
</button>
{douyinHints[0] && (
<button
type="button"
className="btn btn-secondary btn--sm"
onClick={() => openExternalUrl(`https://live.douyin.com/${douyinHints[0]}`)}
>
</button>
)}
</div>
</div>
<div className="live-command__stats" role="list">
<div className="live-command__stat" role="listitem">
<span></span>
<strong>{multiChannelPro ? '多频道 Pro' : '单频道'}</strong>
</div>
<div className="live-command__stat" role="listitem">
<span></span>
<span></span>
<strong>
{multiChannelPro
? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 直播中`
: statusMeta.line}
</strong>
<em>{liveBusinessLabel}</em>
</div>
<div className="live-command__stat" role="listitem">
<span></span>
<strong>
{multiChannelPro
? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置`
: singleKeyConfigured ? '已配置' : '未配置'}
</strong>
</div>
<div className="live-command__stat" role="listitem">
<span></span>
<strong>{multiChannelPro ? `${proSourceCount}` : `${singleSourceCount}`}</strong>
<span></span>
<strong>{configSummaryText}</strong>
<em>{configSummarySubText}</em>
</div>
<div className="live-command__stat" role="listitem">
<span></span>
<strong>{gpuStatusText}</strong>
<strong title={gpuStatusText}>{gpuSummaryText}</strong>
<em></em>
</div>
{multiChannelPro && selectedProRow && (
<div className="live-command__stat" role="listitem">
<span></span>
<strong>{selectedProRow.channelName}</strong>
<em>{selectedProRow.sourceCount} </em>
</div>
)}
</div>
<div className="live-pipeline" aria-label="直播流水线">
<div className="live-pipeline__step live-pipeline__step--source">
<span></span>
<strong></strong>
</div>
<div className="live-pipeline__arrow" aria-hidden></div>
<div className="live-pipeline__step live-pipeline__step--encode">
<span></span>
<strong>FFmpeg / GPU</strong>
</div>
<div className="live-pipeline__arrow" aria-hidden></div>
<div className="live-pipeline__step live-pipeline__step--youtube">
<span>YouTube</span>
<strong>RTMP </strong>
</div>
</div>
<div className="live-preflight-grid" aria-label="开播前体检">
{preflightItems.map((item) => (
<div
key={item.label}
className={`live-preflight-item ${item.ok ? 'is-ok' : 'is-warn'}`}
>
<span className="live-preflight-item__dot" aria-hidden />
<div className="live-preflight-item__body">
<strong>{item.label}</strong>
<span>{item.detail}</span>
</div>
</div>
))}
</div>
</section>
)}
@@ -2592,7 +2468,8 @@ export default function App() {
</section>
)}
<section className="card card--log">
</div>
<aside className="card card--log app-record-layout__log">
<div className="log-head">
<h2 className="card__title card__title--inline"></h2>
<span className="live-tag"></span>
@@ -2693,11 +2570,12 @@ export default function App() {
{controlNotice ? (
<div className="live-log-detail__block">
<div className="live-log-detail__k"></div>
<pre className="live-log-detail__pre">{controlNotice}</pre>
<pre className="live-log-detail__pre">{controlNotice}</pre>
</div>
) : null}
</div>
</section>
</aside>
</div>
</main>
)}
</div>

View File

@@ -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() {
</h2>
<p className="card__desc">
Windows Electron
Windows Electron
</p>
</div>
<div className="app-host-version">
@@ -204,13 +206,21 @@ export default function AppHostControls() {
>
</button>
<button
type="button"
className="btn btn-secondary btn--sm"
onClick={() => void runHost(() => host.registerProtocol?.(), '已注册系统快捷入口')}
>
</button>
<button type="button" className="btn btn-danger btn--sm" onClick={() => void host.relaunch?.()}>
</button>
</div>
<p className="app-host-hint">
Electron {info?.electron || '—'} · Chrome {info?.chrome || '—'} · Node {info?.node || '—'} ·{' '}
{info?.platform || '—'} {info?.arch || ''}
{info?.platform || '—'} {info?.arch || ''} · {info?.protocol || 'd2yexeapp'}://{' '}
{info?.protocolRegistered ? '已注册' : '未确认'}
</p>
</div>

View File

@@ -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<string, unknown>
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<string, unknown>
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 <pre className="ws-pre"></pre>
const d = data as Record<string, unknown>
@@ -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 (
<section className="card card--workspace" aria-labelledby="ws-title">
@@ -196,12 +288,18 @@ export default function WorkspaceDashboard() {
<div className="ws-head">
<div>
<h2 className="card__title" id="ws-title">
</h2>
<p className="ws-head__sub">线</p>
</div>
<div className="ws-head__actions">
<span className={`ws-status-chip ${proLive?.processOnline ? 'is-on' : 'is-idle'}`}>
{proMode ? (proLive?.processOnline ? 'Pro 进程运行中' : 'Pro 进程未运行') : '单频道模式'}
</span>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
</button>
</div>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
</button>
</div>
{err && (
@@ -210,36 +308,83 @@ export default function WorkspaceDashboard() {
</p>
)}
<div className="ws-cards ws-cards--wide">
<div className="ws-card">
<div className="ws-card__title">YouTube </div>
<ul className="ws-kv-list">
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{overview?.youtubeChannelCount ?? 0}</span>
</li>
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{overview?.relayPairingPolicy || '一源站对应一 YouTube key'}</span>
</li>
</ul>
{overview?.youtubeChannels?.length ? (
<div className="ws-card__notes">
{overview.youtubeChannels.slice(0, 6).map((c) => (
<p key={`${c.name}-${c.keyPreview}`}>
{c.name} · key {c.keyPreview || '未配置'} · {c.douyinPreview || '未配置源站'}
</p>
))}
<div className="ws-kpi-grid">
<div className="ws-kpi-card">
<span></span>
<strong>{fmtNumber(configuredChannels)}/{fmtNumber(overview?.youtubeChannelCount ?? channels.length)}</strong>
<em>{overview?.relayPairingPolicy || '一源站对应一 YouTube key'}</em>
</div>
<div className="ws-kpi-card">
<span></span>
<strong>{proMode ? fmtNumber(liveRowCount) : fmtNumber(liveEventsHour)}</strong>
<em>{proMode ? 'Pro 线路实况' : '近 1 小时事件'}</em>
</div>
<div className="ws-kpi-card">
<span></span>
<strong>{fmtNumber(liveEventsToday)}</strong>
<em></em>
</div>
<div className={`ws-kpi-card ${validationErrorCount || clientErrorCount ? 'is-warn' : 'is-ok'}`}>
<span></span>
<strong>{fmtNumber(validationErrorCount + clientErrorCount)}</strong>
<em>{validationErrorCount ? '配置需处理' : clientErrorCount ? '日志需关注' : '未发现明显风险'}</em>
</div>
</div>
<div className="ws-ops-grid">
<div className="ws-panel ws-panel--channels">
<div className="ws-panel__head">
<div>
<h3 className="ws-panel__title">线</h3>
<p className="ws-panel__desc">
{displayChannels.length ? `显示 ${Math.min(displayChannels.length, 8)} 条已填写线路` : '暂无已填写线路'}
</p>
</div>
<span className="ws-panel__badge">{configuredChannels} </span>
</div>
{displayChannels.length ? (
<div className="ws-channel-list">
{displayChannels.slice(0, 8).map((c) => {
const keyOk = isFilledPreview(c.keyPreview)
const sourceOk = isFilledPreview(c.douyinPreview)
return (
<div key={`${c.name}-${c.keyPreview}-${c.douyinPreview}`} className="ws-channel-row">
<div className="ws-channel-row__main">
<strong title={c.name}>{c.name || '未命名频道'}</strong>
<span title={c.douyinPreview || ''}>{c.douyinPreview || '未配置源站'}</span>
</div>
<div className="ws-channel-row__badges">
<span className={`ws-mini-badge ${keyOk ? 'is-ok' : 'is-warn'}`}>key</span>
<span className={`ws-mini-badge ${sourceOk ? 'is-ok' : 'is-warn'}`}></span>
</div>
</div>
)
})}
</div>
) : (
<p className="ws-card__empty"></p>
<p className="ws-card__empty"> key </p>
)}
</div>
<div className="ws-card">
<div className="ws-card__title"></div>
{overview?.relayValidationErrors?.length ? (
<div className="ws-card__errors">
{overview.relayValidationErrors.slice(0, 5).map((x) => (
<div className="ws-panel ws-panel--health">
<div className="ws-panel__head">
<div>
<h3 className="ws-panel__title"></h3>
<p className="ws-panel__desc"></p>
</div>
</div>
<div className="ws-health-grid">
{healthItems.map((item) => (
<div key={item.label} className={`ws-health-item is-${item.tone}`}>
<span>{item.label}</span>
<strong>{item.value}</strong>
<em>{item.meta}</em>
</div>
))}
</div>
{validationErrorCount ? (
<div className="ws-card__errors ws-card__errors--tight">
{overview?.relayValidationErrors?.slice(0, 4).map((x) => (
<p key={x}>{x}</p>
))}
</div>
@@ -248,30 +393,12 @@ export default function WorkspaceDashboard() {
)}
{overview?.relayNotes?.length ? (
<div className="ws-card__notes">
{overview.relayNotes.slice(0, 4).map((x) => (
{overview.relayNotes.slice(0, 3).map((x) => (
<p key={x}>{x}</p>
))}
</div>
) : null}
</div>
<div className="ws-card">
<div className="ws-card__title"></div>
<ul className="ws-kv-list">
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{overview?.wechat?.configured ? '已配置' : '未配置'}</span>
</li>
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{overview?.wechat?.online ? '在线' : '离线'}</span>
</li>
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{overview?.wechat?.nickName || overview?.wechat?.wcId || '—'}</span>
</li>
</ul>
{overview?.wechat?.proxyError ? <p className="ws-card__errors">{overview.wechat.proxyError}</p> : null}
</div>
</div>
{proMode && (
@@ -344,6 +471,23 @@ export default function WorkspaceDashboard() {
<p className="ws-card__empty"></p>
)}
</div>
<div className="ws-card">
<div className="ws-card__title"></div>
<ul className="ws-kv-list">
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{recentUserActions.length} </span>
</li>
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{recentClientLogs.length} </span>
</li>
<li>
<span className="ws-kv-list__k"></span>
<span className="ws-kv-list__v">{clientErrorCount} </span>
</li>
</ul>
</div>
</div>
<div className="ws-section">

View File

@@ -77,12 +77,17 @@ interface Window {
chrome: string
electron: string
node: string
protocol: string
protocolRegistered: boolean
}>
getLoginItemSettings?: () => Promise<Record<string, unknown>>
setLoginItemOpenAtLogin?: (v: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
relaunch?: () => Promise<void>
getMetrics?: () => Promise<unknown[]>
showAbout?: () => Promise<void>
registerProtocol?: () => Promise<
{ ok: true; registered: boolean; protocol: string } | { ok: false; error: string }
>
setBadge?: (n: number) => Promise<void>
getPaths?: () => Promise<{
userData: string