's'
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 && (
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
5
electron/src/renderer/src/vite-env.d.ts
vendored
5
electron/src/renderer/src/vite-env.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user