'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
This commit is contained in:
224
electron/src/main/appHost.ts
Normal file
224
electron/src/main/appHost.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Electron app 模块:开机启动、跳转列表、关于面板、日志路径、角标、优雅重启、指标等。
|
||||
* 文档:https://www.electronjs.org/docs/latest/api/app
|
||||
*/
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const VALID_NAV = new Set(['desk', 'record', 'network', 'proxy', 'system', 'wechat'])
|
||||
|
||||
export function parseNavFromArgv(argv: string[]): string | null {
|
||||
for (const a of argv) {
|
||||
const m = /^--nav=(.+)$/.exec(a)
|
||||
if (!m) continue
|
||||
const id = m[1].trim()
|
||||
if (VALID_NAV.has(id)) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 须在 app.ready 之前调用(Windows Toast / 任务栏分组) */
|
||||
export function setAppUserModelIdEarly(): void {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
app.setAppUserModelId('com.douyin.recorder.console')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageJsonField(field: 'description' | 'version'): string {
|
||||
try {
|
||||
const candidates = [
|
||||
path.join(app.getAppPath(), 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
const j = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
const v = j[field]
|
||||
if (typeof v === 'string') return v
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** ready 后立即调用 */
|
||||
export function applyAppHostBootstrap(getBackendRoot: () => string): void {
|
||||
try {
|
||||
const logDir = path.join(app.getPath('userData'), 'logs')
|
||||
app.setAppLogsPath(logDir)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const ver = app.getVersion()
|
||||
const desc = readPackageJsonField('description') || '直播录制与转播控制台'
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: app.getName(),
|
||||
applicationVersion: ver,
|
||||
copyright: `Copyright © ${new Date().getFullYear()}`,
|
||||
credits: desc,
|
||||
})
|
||||
|
||||
if (process.env.WEB2_SECURE_DNS === '1') {
|
||||
try {
|
||||
app.configureHostResolver({ secureDnsMode: 'automatic' })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = process.env.WEB2_ELECTRON_GLOBAL_PROXY?.trim()
|
||||
if (proxy) {
|
||||
try {
|
||||
void app.setProxy({ proxyRules: proxy })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
app.setUserTasks([
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=desk',
|
||||
title: '工作台',
|
||||
description: '打开工作台仪表盘',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=record',
|
||||
title: '录制',
|
||||
description: '打开录制与转播控制',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=network',
|
||||
title: '网络',
|
||||
description: '网络与远程绑定',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAppHostIpcHandlers(getBackendRoot: () => string): void {
|
||||
ipcMain.removeHandler('app:get-login-item')
|
||||
ipcMain.handle('app:get-login-item', () => {
|
||||
try {
|
||||
return app.getLoginItemSettings()
|
||||
} catch {
|
||||
return { openAtLogin: false }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-login-item')
|
||||
ipcMain.handle('app:set-login-item', (_evt, openAtLogin: unknown) => {
|
||||
if (typeof openAtLogin !== 'boolean') return { ok: false as const, error: '参数无效' }
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin,
|
||||
path: process.execPath,
|
||||
args: [],
|
||||
})
|
||||
return { ok: true as const }
|
||||
} catch (e) {
|
||||
return { ok: false as const, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:relaunch')
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:get-metrics')
|
||||
ipcMain.handle('app:get-metrics', () => {
|
||||
try {
|
||||
return app.getAppMetrics()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:show-about')
|
||||
ipcMain.handle('app:show-about', () => {
|
||||
try {
|
||||
app.showAboutPanel()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-badge')
|
||||
ipcMain.handle('app:set-badge', (_evt, count: unknown) => {
|
||||
const n = typeof count === 'number' && Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
try {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
app.setBadgeCount(n)
|
||||
}
|
||||
} catch {
|
||||
/* Windows 无 dock 角标,忽略 */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:get-paths')
|
||||
ipcMain.handle('app:get-paths', () => {
|
||||
const br = getBackendRoot()
|
||||
return {
|
||||
userData: app.getPath('userData'),
|
||||
logs: app.getPath('logs'),
|
||||
temp: app.getPath('temp'),
|
||||
backendRoot: br,
|
||||
backendConfig: path.join(br, 'config'),
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:add-recent-config')
|
||||
ipcMain.handle('app:add-recent-config', () => {
|
||||
const cfg = path.join(getBackendRoot(), 'config')
|
||||
try {
|
||||
if (fs.existsSync(cfg)) {
|
||||
app.addRecentDocument(cfg)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:open-logs-folder')
|
||||
ipcMain.handle('app:open-logs-folder', async () => {
|
||||
const p = app.getPath('logs')
|
||||
try {
|
||||
await fs.promises.mkdir(p, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const err = await shell.openPath(p)
|
||||
if (err) throw new Error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendNavToWindow(win: BrowserWindow, nav: string): void {
|
||||
if (!VALID_NAV.has(nav)) return
|
||||
try {
|
||||
win.webContents.send('app:nav', nav)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
400
electron/src/main/clientDb.ts
Normal file
400
electron/src/main/clientDb.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 客户端本地 SQLite(sql.js):直播事件、用户操作、运行日志、数据快照。
|
||||
* 数据库文件:userData/client-data.sqlite
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import initSqlJs, { type Database } from 'sql.js'
|
||||
|
||||
let db: Database | null = null
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function getDbPath(): string {
|
||||
return path.join(app.getPath('userData'), 'client-data.sqlite')
|
||||
}
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null
|
||||
flushClientDb()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
export function flushClientDb(): void {
|
||||
if (!db) return
|
||||
try {
|
||||
const buf = Buffer.from(db.export())
|
||||
fs.mkdirSync(path.dirname(getDbPath()), { recursive: true })
|
||||
fs.writeFileSync(getDbPath(), buf)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(): void {
|
||||
if (!db) return
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS live_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
process TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
detail_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
nav TEXT,
|
||||
detail_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS client_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
source TEXT,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS data_snapshots (
|
||||
key TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_ts ON live_events(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_ts ON user_actions(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_ts ON client_logs(ts DESC);
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS relay_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
youtube_key TEXT NOT NULL DEFAULT '',
|
||||
douyin_url TEXT NOT NULL DEFAULT '',
|
||||
url_list_text TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function initClientDb(): Promise<void> {
|
||||
const require = createRequire(import.meta.url)
|
||||
// sql.js exports "." and "./dist/*" only — not package.json
|
||||
const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm')
|
||||
const SQL = await initSqlJs({ locateFile: () => wasmPath })
|
||||
const dbPath = getDbPath()
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const fileBuf = fs.readFileSync(dbPath)
|
||||
db = new SQL.Database(fileBuf)
|
||||
} else {
|
||||
db = new SQL.Database()
|
||||
}
|
||||
runMigrations()
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendLiveEvent(payload: {
|
||||
eventType: string
|
||||
process?: string
|
||||
status?: string
|
||||
message?: string
|
||||
detail?: unknown
|
||||
}): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
|
||||
db.run(
|
||||
`INSERT INTO live_events (ts, process, event_type, status, message, detail_json) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
ts,
|
||||
payload.process ?? null,
|
||||
payload.eventType,
|
||||
payload.status ?? null,
|
||||
payload.message ?? null,
|
||||
detailJson,
|
||||
],
|
||||
)
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendUserAction(payload: { action: string; nav?: string; detail?: unknown }): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
|
||||
db.run(`INSERT INTO user_actions (ts, action, nav, detail_json) VALUES (?, ?, ?, ?)`, [
|
||||
ts,
|
||||
payload.action,
|
||||
payload.nav ?? null,
|
||||
detailJson,
|
||||
])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendClientLog(level: string, source: string, message: string): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(`INSERT INTO client_logs (ts, level, source, message) VALUES (?, ?, ?, ?)`, [
|
||||
ts,
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function saveDataSnapshot(key: string, json: unknown): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const text = JSON.stringify(json)
|
||||
db.run(`INSERT OR REPLACE INTO data_snapshots (key, json, ts) VALUES (?, ?, ?)`, [key, text, ts])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export type DashboardRow = {
|
||||
id: number
|
||||
ts: number
|
||||
process?: string | null
|
||||
event_type?: string
|
||||
status?: string | null
|
||||
message?: string | null
|
||||
detail_json?: string | null
|
||||
action?: string
|
||||
nav?: string | null
|
||||
level?: string
|
||||
source?: string | null
|
||||
}
|
||||
|
||||
function execToRows(sql: string): DashboardRow[] {
|
||||
if (!db) return []
|
||||
try {
|
||||
const res = db.exec(sql)
|
||||
if (!res.length) return []
|
||||
const { columns, values } = res[0]
|
||||
return values.map((row) => {
|
||||
const o: Record<string, unknown> = {}
|
||||
columns.forEach((c, i) => {
|
||||
o[c] = row[i]
|
||||
})
|
||||
return o as DashboardRow
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboardSnapshot(): {
|
||||
liveEvents: DashboardRow[]
|
||||
userActions: DashboardRow[]
|
||||
clientLogs: DashboardRow[]
|
||||
snapshots: Record<string, { ts: number; data: unknown }>
|
||||
} {
|
||||
const empty = {
|
||||
liveEvents: [] as DashboardRow[],
|
||||
userActions: [] as DashboardRow[],
|
||||
clientLogs: [] as DashboardRow[],
|
||||
snapshots: {} as Record<string, { ts: number; data: unknown }>,
|
||||
}
|
||||
if (!db) return empty
|
||||
|
||||
try {
|
||||
empty.liveEvents = execToRows(
|
||||
`SELECT id, ts, process, event_type, status, message, detail_json FROM live_events ORDER BY id DESC LIMIT 80`,
|
||||
)
|
||||
empty.userActions = execToRows(
|
||||
`SELECT id, ts, action, nav, detail_json FROM user_actions ORDER BY id DESC LIMIT 80`,
|
||||
)
|
||||
empty.clientLogs = execToRows(
|
||||
`SELECT id, ts, level, source, message FROM client_logs ORDER BY id DESC LIMIT 120`,
|
||||
)
|
||||
|
||||
const snapRes = db.exec(`SELECT key, json, ts FROM data_snapshots`)
|
||||
if (snapRes.length) {
|
||||
const { columns, values } = snapRes[0]
|
||||
for (const row of values) {
|
||||
const o: Record<string, unknown> = {}
|
||||
columns.forEach((c, i) => {
|
||||
o[c] = row[i]
|
||||
})
|
||||
const key = String(o.key ?? '')
|
||||
const json = String(o.json ?? '')
|
||||
const ts = Number(o.ts ?? 0)
|
||||
try {
|
||||
empty.snapshots[key] = { ts, data: JSON.parse(json) as unknown }
|
||||
} catch {
|
||||
empty.snapshots[key] = { ts, data: json }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
// —— 应用设置(主题等)——
|
||||
|
||||
export function getAppSetting(key: string): string | null {
|
||||
if (!db) return null
|
||||
try {
|
||||
const r = db.exec(`SELECT value FROM app_settings WHERE key = ${JSON.stringify(key)}`)
|
||||
if (!r.length || !r[0].values.length) return null
|
||||
return String(r[0].values[0][0] ?? '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppSetting(key: string, value: string): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(`INSERT OR REPLACE INTO app_settings (key, value, ts) VALUES (?, ?, ?)`, [key, value, ts])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
// —— Pro 转播线路:SQLite 为源,导出到项目 config/ ——
|
||||
|
||||
function relayChannelCount(): number {
|
||||
if (!db) return 0
|
||||
try {
|
||||
const r = db.exec(`SELECT COUNT(*) AS c FROM relay_channels`)
|
||||
if (!r.length || !r[0].values.length) return 0
|
||||
return Number(r[0].values[0][0] ?? 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaultRelayChannel(): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
'ch_default',
|
||||
'默认线路',
|
||||
'',
|
||||
'',
|
||||
'# 软件已自动创建本文件。每行一个抖音直播间地址,例如:\n# https://live.douyin.com/123456789\n\n',
|
||||
0,
|
||||
ts,
|
||||
],
|
||||
)
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
function safeChannelFileId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default'
|
||||
}
|
||||
|
||||
function importRelayFromYoutubeJson(configDir: string): boolean {
|
||||
if (!db) return false
|
||||
const p = path.join(configDir, 'youtube_channels.json')
|
||||
if (!fs.existsSync(p)) return false
|
||||
let raw: { channels?: unknown[] }
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as { channels?: unknown[] }
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const arr = raw.channels
|
||||
if (!Array.isArray(arr) || arr.length === 0) return false
|
||||
db.run(`DELETE FROM relay_channels`)
|
||||
const relayPro = path.join(configDir, 'relay_pro')
|
||||
let order = 0
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const o = item as Record<string, unknown>
|
||||
const id = String(o.id ?? o.name ?? `ch_${order}`).trim() || `ch_${order}`
|
||||
const name = String(o.name ?? '线路').trim() || '线路'
|
||||
const yk = String(o.youtubeKey ?? o.key ?? '').trim()
|
||||
const du = String(o.douyinUrl ?? o.douyin_url ?? '').trim()
|
||||
let urlText = ''
|
||||
const up = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`)
|
||||
if (fs.existsSync(up)) {
|
||||
try {
|
||||
urlText = fs.readFileSync(up, 'utf-8')
|
||||
} catch {
|
||||
urlText = ''
|
||||
}
|
||||
}
|
||||
const ts = Date.now()
|
||||
db.run(
|
||||
`INSERT INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, name, yk, du, urlText, order, ts],
|
||||
)
|
||||
order += 1
|
||||
}
|
||||
schedulePersist()
|
||||
return order > 0
|
||||
}
|
||||
|
||||
export function exportRelayChannelsToConfigDir(configDir: string): void {
|
||||
if (!db) return
|
||||
const relayPro = path.join(configDir, 'relay_pro')
|
||||
fs.mkdirSync(relayPro, { recursive: true })
|
||||
let r: ReturnType<Database['exec']>
|
||||
try {
|
||||
r = db.exec(
|
||||
`SELECT id, name, youtube_key, douyin_url, url_list_text, sort_order FROM relay_channels ORDER BY sort_order, id`,
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!r.length || !r[0].values.length) return
|
||||
const cols = r[0].columns
|
||||
const rows = r[0].values
|
||||
const channels: Record<string, unknown>[] = []
|
||||
for (const row of rows) {
|
||||
const rec: Record<string, unknown> = {}
|
||||
cols.forEach((c, i) => {
|
||||
rec[c] = row[i]
|
||||
})
|
||||
const id = String(rec.id ?? '')
|
||||
const name = String(rec.name ?? '')
|
||||
const youtubeKey = String(rec.youtube_key ?? '')
|
||||
const douyinUrl = String(rec.douyin_url ?? '')
|
||||
const urlList = String(rec.url_list_text ?? '')
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
douyinUrl,
|
||||
youtubeKey,
|
||||
})
|
||||
const uf = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`)
|
||||
fs.writeFileSync(uf, urlList || '# 每行一个抖音直播间地址\n', 'utf-8')
|
||||
}
|
||||
const doc = {
|
||||
version: 1,
|
||||
autoManaged: true,
|
||||
channels,
|
||||
}
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(configDir, 'youtube_channels.json'), JSON.stringify(doc, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/** 启动时:SQLite 优先;若为空则从磁盘 JSON 导入;仍空则种子;最后导出到 config/ */
|
||||
export function syncRelayChannelsWithBackendConfig(configDir: string): void {
|
||||
if (!db) return
|
||||
const abs = path.resolve(configDir)
|
||||
if (relayChannelCount() === 0) {
|
||||
if (!importRelayFromYoutubeJson(abs)) {
|
||||
seedDefaultRelayChannel()
|
||||
}
|
||||
}
|
||||
exportRelayChannelsToConfigDir(abs)
|
||||
}
|
||||
|
||||
/** 从磁盘重新读入 SQLite(在通过 Python API 保存文件后调用) */
|
||||
export function importRelayChannelsFromConfigDir(configDir: string): void {
|
||||
if (!db) return
|
||||
const abs = path.resolve(configDir)
|
||||
importRelayFromYoutubeJson(abs)
|
||||
}
|
||||
229
electron/src/main/easytier.ts
Normal file
229
electron/src/main/easytier.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 内置 EasyTier(Windows):随应用分发 easytier-core.exe,自动生成组网密钥,无需用户单独安装。
|
||||
* 二进制来源:https://github.com/EasyTier/EasyTier (LGPL-3.0)
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
import { execFile, spawn, type ChildProcess } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type EasyTierNetworkConfig = {
|
||||
networkName: string
|
||||
networkSecret: string
|
||||
peerUrls: string[]
|
||||
}
|
||||
|
||||
let easytierChild: ChildProcess | null = null
|
||||
let easytierLastError = ''
|
||||
|
||||
/** Windows 进程退出码(含 NTSTATUS)可读说明 */
|
||||
function formatWindowsExitHint(code: number): string {
|
||||
const u = code >>> 0
|
||||
if (u === 0xc0000135) {
|
||||
return ' — 缺少 DLL(常见:官方 zip 内依赖未与 exe 同目录;开发环境请删除 electron/resources/easytier 后重新执行 pnpm run prepare-easytier)。也可能是未安装 VC++ 运行库。'
|
||||
}
|
||||
if (u === 0xc000007b) {
|
||||
return ' — 无效映像(32/64 位与系统不符或文件损坏)。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getBundledEasyTierExePath(): string | null {
|
||||
if (process.platform !== 'win32') return null
|
||||
const exe = 'easytier-core.exe'
|
||||
if (app.isPackaged) {
|
||||
const p = path.join(process.resourcesPath, 'easytier', exe)
|
||||
return fs.existsSync(p) ? p : null
|
||||
}
|
||||
const dev = path.resolve(__dirname, '../../resources/easytier', exe)
|
||||
return fs.existsSync(dev) ? dev : null
|
||||
}
|
||||
|
||||
/** 与 easytier-core 同目录分发的 CLI,用于 peer list 等工作台统计 */
|
||||
export function getBundledEasyTierCliPath(): string | null {
|
||||
if (process.platform !== 'win32') return null
|
||||
const exe = 'easytier-cli.exe'
|
||||
if (app.isPackaged) {
|
||||
const p = path.join(process.resourcesPath, 'easytier', exe)
|
||||
return fs.existsSync(p) ? p : null
|
||||
}
|
||||
const dev = path.resolve(__dirname, '../../resources/easytier', exe)
|
||||
return fs.existsSync(dev) ? dev : null
|
||||
}
|
||||
|
||||
export type EasyTierPeerStats = {
|
||||
ok: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
raw?: string
|
||||
}
|
||||
|
||||
function parsePeerListOutput(text: string): { totalPeers: number; onlinePeers: number } {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
const ipRe = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g
|
||||
let withIp = 0
|
||||
let online = 0
|
||||
for (const line of lines) {
|
||||
if (/^[-=│├└┌┐┘┬┴┼]+$/.test(line)) continue
|
||||
if (/^peer\s|^id\s|^hostname\s/i.test(line) && line.length < 100) continue
|
||||
const ips = line.match(ipRe)
|
||||
if (!ips) continue
|
||||
withIp += 1
|
||||
const low = line.toLowerCase()
|
||||
if (!/offline|unreachable|disconnected|lost|timeout/.test(low)) online += 1
|
||||
}
|
||||
if (withIp === 0) {
|
||||
const guess = lines.filter((l) => l.length > 12 && !/^error|^usage/i.test(l)).length
|
||||
const n = Math.max(0, guess > 0 ? guess - 1 : 0)
|
||||
return { totalPeers: n, onlinePeers: n }
|
||||
}
|
||||
return { totalPeers: withIp, onlinePeers: online > 0 ? online : withIp }
|
||||
}
|
||||
|
||||
/** 调用 easytier-cli peer list;需 easytier-core 已运行且 RPC 端口可达 */
|
||||
export async function queryEasyTierPeerStats(): Promise<EasyTierPeerStats> {
|
||||
const cli = getBundledEasyTierCliPath()
|
||||
if (!cli) return { ok: false, error: '未找到内置 easytier-cli.exe' }
|
||||
if (!getEasyTierRuntimeState().running) {
|
||||
return { ok: false, error: 'easytier-core 未运行', totalPeers: 0, onlinePeers: 0 }
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(cli, ['peer', 'list'], {
|
||||
cwd: path.dirname(cli),
|
||||
timeout: 5000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
})
|
||||
const raw = `${stdout || ''}${stderr || ''}`
|
||||
const { totalPeers, onlinePeers } = parsePeerListOutput(raw)
|
||||
return { ok: true, totalPeers, onlinePeers, raw: raw.slice(0, 6000) }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: msg, totalPeers: 0, onlinePeers: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export function readEasyTierNetworkName(backendRoot: string): string {
|
||||
const p = path.join(backendRoot, 'config', 'easytier_network.json')
|
||||
if (!fs.existsSync(p)) return ''
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
return String(raw.networkName ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrCreateNetworkConfig(backendRoot: string): EasyTierNetworkConfig {
|
||||
const p = path.join(backendRoot, 'config', 'easytier_network.json')
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true })
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
return {
|
||||
networkName: String(raw.networkName ?? ''),
|
||||
networkSecret: String(raw.networkSecret ?? ''),
|
||||
peerUrls: Array.isArray(raw.peerUrls) ? raw.peerUrls.map(String) : [],
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const cfg: EasyTierNetworkConfig = {
|
||||
networkName: `lr_${crypto.randomBytes(6).toString('hex')}`,
|
||||
networkSecret: crypto.randomBytes(24).toString('hex'),
|
||||
peerUrls: [],
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf-8')
|
||||
return cfg
|
||||
}
|
||||
|
||||
export function getEasyTierRuntimeState(): {
|
||||
bundled: boolean
|
||||
running: boolean
|
||||
lastError: string
|
||||
} {
|
||||
const bundled = !!getBundledEasyTierExePath()
|
||||
const running = !!easytierChild && !easytierChild.killed
|
||||
return { bundled, running, lastError: easytierLastError }
|
||||
}
|
||||
|
||||
export function startBundledEasyTier(backendRoot: string): void {
|
||||
easytierLastError = ''
|
||||
const exe = getBundledEasyTierExePath()
|
||||
if (!exe) {
|
||||
easytierLastError = '未找到内置 easytier-core.exe(开发环境可执行 pnpm run prepare-easytier)'
|
||||
return
|
||||
}
|
||||
if (easytierChild && !easytierChild.killed) {
|
||||
return
|
||||
}
|
||||
const cfg = loadOrCreateNetworkConfig(backendRoot)
|
||||
const args = ['-d', '--network-name', cfg.networkName, '--network-secret', cfg.networkSecret]
|
||||
for (const u of cfg.peerUrls) {
|
||||
const t = u.trim()
|
||||
if (t) args.push('-p', t)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
cwd: path.dirname(exe),
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
})
|
||||
} catch (e) {
|
||||
easytierLastError = e instanceof Error ? e.message : String(e)
|
||||
easytierChild = null
|
||||
return
|
||||
}
|
||||
easytierChild = child
|
||||
const appendErr = (chunk: Buffer): void => {
|
||||
const s = chunk.toString()
|
||||
if (s.length < 2000) easytierLastError = (easytierLastError + s).slice(-2000)
|
||||
}
|
||||
child.stderr?.on('data', appendErr)
|
||||
child.stdout?.on('data', appendErr)
|
||||
child.on('error', (err) => {
|
||||
easytierLastError = err.message
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
const hint = formatWindowsExitHint(code)
|
||||
easytierLastError = `easytier-core 已退出(代码 ${code})${hint}${easytierLastError ? `\n${easytierLastError}` : ''}`
|
||||
}
|
||||
easytierChild = null
|
||||
})
|
||||
}
|
||||
|
||||
export function stopBundledEasyTier(): void {
|
||||
if (!easytierChild || easytierChild.killed) {
|
||||
easytierChild = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/PID', String(easytierChild.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} else {
|
||||
easytierChild.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
easytierChild = null
|
||||
}
|
||||
706
electron/src/main/index.ts
Normal file
706
electron/src/main/index.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { execFile, spawn, ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { networkInterfaces } from 'os'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import {
|
||||
getEasyTierRuntimeState,
|
||||
loadOrCreateNetworkConfig,
|
||||
queryEasyTierPeerStats,
|
||||
readEasyTierNetworkName,
|
||||
startBundledEasyTier,
|
||||
stopBundledEasyTier,
|
||||
} from './easytier'
|
||||
import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar'
|
||||
import {
|
||||
appendClientLog,
|
||||
appendLiveEvent,
|
||||
appendUserAction,
|
||||
flushClientDb,
|
||||
getAppSetting,
|
||||
getDashboardSnapshot,
|
||||
importRelayChannelsFromConfigDir,
|
||||
initClientDb,
|
||||
saveDataSnapshot,
|
||||
setAppSetting,
|
||||
syncRelayChannelsWithBackendConfig,
|
||||
} from './clientDb'
|
||||
import {
|
||||
applyAppHostBootstrap,
|
||||
parseNavFromArgv,
|
||||
registerAppHostIpcHandlers,
|
||||
sendNavToWindow,
|
||||
setAppUserModelIdEarly,
|
||||
} from './appHost'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const PORT = Number(process.env.WEB2_PORT || 8001)
|
||||
/** uvicorn 监听地址;0.0.0.0 时建议配置 config/web2_api_token.txt 供远程/EasyTier 访问 */
|
||||
let web2Host = '127.0.0.1'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let pythonChild: ChildProcessWithoutNullStreams | null = null
|
||||
let stderrBuf = ''
|
||||
let _backendRoot: string | null = null
|
||||
let _pythonExe: string | null = null
|
||||
/** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */
|
||||
let pendingNav: string | null = null
|
||||
|
||||
/**
|
||||
* 供扫码绑定 baseUrl:优先虚拟网卡(EasyTier 等),其次 10.x,再补充 Wi‑Fi/以太网等局域网 IPv4,
|
||||
* 避免仅有虚拟网时列表为空、用户无处选 IP。
|
||||
*/
|
||||
function collectEasyTierCandidateIPs(): { address: string; name: string }[] {
|
||||
const nets = networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const out: { address: string; name: string }[] = []
|
||||
const nameMatch = /easytier|wintun|tun|tap|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx/i
|
||||
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (nameMatch.test(name) && !seen.has(a.address)) {
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.length === 0) {
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (a.address.startsWith('10.') && !seen.has(a.address)) {
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name: `${name}(10.x,可能含 EasyTier)` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (a.address === '127.0.0.1') continue
|
||||
if (seen.has(a.address)) continue
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name: `${name}(局域网)` })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const py = _pythonExe ?? resolvePythonExe(br)
|
||||
web2Host = resolveWeb2Host()
|
||||
await killPythonProcessForRestart()
|
||||
startPythonBackend(br, py)
|
||||
try {
|
||||
await waitForHealth(90000)
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换监听地址 / 轮换 API 密钥时必须等旧 uvicorn 完全退出并释放端口。
|
||||
* Windows 上若仅 spawn taskkill 不等待,旧进程仍占用 8001,新进程启动失败,但 /health 仍由旧进程响应,
|
||||
* waitForHealth 误判成功,随后旧进程被杀死 → 前端 ERR_CONNECTION_REFUSED。
|
||||
*/
|
||||
async function killPythonProcessForRestart(): Promise<void> {
|
||||
if (!pythonChild || pythonChild.killed) {
|
||||
pythonChild = null
|
||||
return
|
||||
}
|
||||
const child = pythonChild
|
||||
const pid = child.pid
|
||||
pythonChild = null
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
timeout: 25000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
} catch {
|
||||
/* 进程已退出等 */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
} else {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(() => resolve(), 10000)
|
||||
child.once('close', () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendRoot(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'backend')
|
||||
}
|
||||
// dev: electron/out/main -> 仓库根(上三级)
|
||||
return path.resolve(__dirname, '../../..')
|
||||
}
|
||||
|
||||
function resolveWeb2Host(): string {
|
||||
const e = process.env.WEB2_HOST?.trim()
|
||||
if (e) return e
|
||||
const br = getBackendRoot()
|
||||
const p = path.join(br, 'config', 'web2_bind_host.txt')
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
|
||||
if (line) return line
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
function resolveWeb2ApiTokenForChild(): string | undefined {
|
||||
const e = process.env.WEB2_API_TOKEN?.trim()
|
||||
if (e) return e
|
||||
const br = getBackendRoot()
|
||||
const p = path.join(br, 'config', 'web2_api_token.txt')
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
|
||||
if (line) return line
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolvePythonExe(backendRoot: string): string {
|
||||
if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) {
|
||||
return process.env.PYTHON_EXE
|
||||
}
|
||||
const embedded = path.join(backendRoot, 'python', 'python.exe')
|
||||
if (fs.existsSync(embedded)) return embedded
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
function stderrTail(): string {
|
||||
const s = stderrBuf.trim()
|
||||
if (!s) {
|
||||
return '若您使用的是完整安装包仍无法启动,请尝试重启电脑后再次打开本软件,或联系技术支持获取帮助。'
|
||||
}
|
||||
return s.slice(-4000)
|
||||
}
|
||||
|
||||
/** 懒人包:把自带的 ffmpeg / 便携 Python 放进 PATH,用户无需自行安装 */
|
||||
function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env } as NodeJS.ProcessEnv
|
||||
const sep = path.delimiter
|
||||
const prepend: string[] = []
|
||||
|
||||
const ffmpegDirs = [
|
||||
path.join(backendRoot, 'tools', 'ffmpeg'),
|
||||
path.join(backendRoot, 'tools', 'ffmpeg', 'bin'),
|
||||
]
|
||||
for (const dir of ffmpegDirs) {
|
||||
if (fs.existsSync(dir)) prepend.push(dir)
|
||||
}
|
||||
|
||||
const portablePython = path.join(backendRoot, 'python')
|
||||
if (fs.existsSync(portablePython)) {
|
||||
prepend.push(portablePython)
|
||||
const scripts = path.join(portablePython, 'Scripts')
|
||||
if (fs.existsSync(scripts)) prepend.push(scripts)
|
||||
env.PYTHONHOME = portablePython
|
||||
}
|
||||
|
||||
if (prepend.length && env.PATH) {
|
||||
env.PATH = `${prepend.join(sep)}${sep}${env.PATH}`
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** 开发模式:等 Vite 监听后再 loadURL,避免先打开窗口却加载失败导致黑屏 */
|
||||
function waitForDevServer(devUrl: string, timeoutMs = 60000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(devUrl)
|
||||
} catch {
|
||||
reject(new Error(`无效的 ELECTRON_RENDERER_URL: ${devUrl}`))
|
||||
return
|
||||
}
|
||||
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
|
||||
const start = Date.now()
|
||||
const tryOnce = (): void => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
reject(
|
||||
new Error(
|
||||
`开发服务器在 ${timeoutMs / 1000}s 内未响应(${devUrl})。请先保持 pnpm dev / electron-vite dev 运行,或检查端口是否被占用。`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
const req = http.get(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port,
|
||||
path: '/',
|
||||
timeout: 2500,
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
req.on('error', () => {
|
||||
req.destroy()
|
||||
setTimeout(tryOnce, 350)
|
||||
})
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
setTimeout(tryOnce, 350)
|
||||
})
|
||||
}
|
||||
tryOnce()
|
||||
})
|
||||
}
|
||||
|
||||
function healthCheckHost(): string {
|
||||
return web2Host === '0.0.0.0' ? '127.0.0.1' : web2Host
|
||||
}
|
||||
|
||||
function waitForHealth(timeoutMs = 120000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const tick = (): void => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(iv)
|
||||
reject(new Error(`本地服务在 ${timeoutMs / 1000} 秒内未就绪。\n\n${stderrTail()}`))
|
||||
return
|
||||
}
|
||||
const req = http.get(`http://${healthCheckHost()}:${PORT}/health`, (res) => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearInterval(iv)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
req.on('error', () => req.destroy())
|
||||
}
|
||||
const iv = setInterval(tick, 400)
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
function startPythonBackend(backendRoot: string, pythonExe: string): void {
|
||||
stderrBuf = ''
|
||||
const apiTok = resolveWeb2ApiTokenForChild()
|
||||
if (web2Host === '0.0.0.0' && !apiTok) {
|
||||
console.warn(
|
||||
'[直播录制助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txt,API 无鉴权,请勿暴露到公网。',
|
||||
)
|
||||
}
|
||||
const env = {
|
||||
...buildChildEnv(backendRoot),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
WEB2_SUPERVISOR: 'builtin',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
...(apiTok ? { WEB2_API_TOKEN: apiTok } : {}),
|
||||
}
|
||||
const args = ['-m', 'uvicorn', 'web2:app', '--host', web2Host, '--port', String(PORT)]
|
||||
pythonChild = spawn(pythonExe, args, {
|
||||
cwd: backendRoot,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
pythonChild.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.stdout?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.on('error', (err: Error) => {
|
||||
stderrBuf += `\nspawn error: ${err.message}`
|
||||
})
|
||||
pythonChild.on('close', (code) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (code !== 0 && code !== null) {
|
||||
dialog.showErrorBox('直播录制助手', `软件内部服务已停止运行。\n\n${stderrTail()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function killPythonTree(): void {
|
||||
if (!pythonChild || pythonChild.killed) return
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/PID', String(pythonChild.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} else {
|
||||
pythonChild.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pythonChild = null
|
||||
}
|
||||
|
||||
function resolvePreload(): string {
|
||||
const base = path.join(__dirname, '../preload')
|
||||
const js = path.join(base, 'index.js')
|
||||
const mjs = path.join(base, 'index.mjs')
|
||||
// 构建产物为 CJS 的 index.js;若目录里残留旧版 index.mjs,须优先使用 index.js
|
||||
if (fs.existsSync(js)) return js
|
||||
if (fs.existsSync(mjs)) return mjs
|
||||
return js
|
||||
}
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1020,
|
||||
height: 720,
|
||||
minWidth: 720,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
backgroundColor: '#14161c',
|
||||
title: '直播录制助手',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: resolvePreload(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
mainWindow.once('ready-to-show', () => mainWindow?.show())
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
appendClientLog('info', 'main', '渲染界面已加载')
|
||||
if (pendingNav && mainWindow && !mainWindow.isDestroyed()) {
|
||||
sendNavToWindow(mainWindow, pendingNav)
|
||||
pendingNav = null
|
||||
}
|
||||
})
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
try {
|
||||
await waitForDevServer(devUrl)
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (process.env.NODE_ENV === 'development' || devUrl.includes('localhost')) {
|
||||
mainWindow?.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
try {
|
||||
await mainWindow.loadURL(devUrl)
|
||||
break
|
||||
} catch {
|
||||
if (i === 7) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`无法加载开发页面:${devUrl}\n请确认 Vite 已启动且端口未被占用。`,
|
||||
)
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
setAppUserModelIdEarly()
|
||||
pendingNav = parseNavFromArgv(process.argv)
|
||||
|
||||
app.on('second-instance', (_evt, commandLine) => {
|
||||
const argv = Array.isArray(commandLine)
|
||||
? commandLine
|
||||
: typeof commandLine === 'string'
|
||||
? commandLine.split(/\s+/).filter(Boolean)
|
||||
: [String(commandLine)]
|
||||
const nav = parseNavFromArgv(argv)
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
if (nav) sendNavToWindow(mainWindow, nav)
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
applyAppHostBootstrap(getBackendRoot)
|
||||
registerAppHostIpcHandlers(getBackendRoot)
|
||||
try {
|
||||
await initClientDb()
|
||||
try {
|
||||
syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config'))
|
||||
} catch (e) {
|
||||
console.error('[relay sync]', e)
|
||||
}
|
||||
appendClientLog('info', 'main', '客户端主进程就绪')
|
||||
} catch (e) {
|
||||
console.error('[clientDb]', e)
|
||||
}
|
||||
|
||||
ipcMain.removeHandler('workspace:log-live')
|
||||
ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const eventType = typeof p.eventType === 'string' ? p.eventType : 'unknown'
|
||||
appendLiveEvent({
|
||||
eventType,
|
||||
process: typeof p.process === 'string' ? p.process : undefined,
|
||||
status: typeof p.status === 'string' ? p.status : undefined,
|
||||
message: typeof p.message === 'string' ? p.message : undefined,
|
||||
detail: p.detail,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:log-user')
|
||||
ipcMain.handle('workspace:log-user', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const action = typeof p.action === 'string' ? p.action : 'unknown'
|
||||
appendUserAction({
|
||||
action,
|
||||
nav: typeof p.nav === 'string' ? p.nav : undefined,
|
||||
detail: p.detail,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:log-client')
|
||||
ipcMain.handle('workspace:log-client', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const level = typeof p.level === 'string' ? p.level : 'info'
|
||||
const source = typeof p.source === 'string' ? p.source : 'renderer'
|
||||
const message = typeof p.message === 'string' ? p.message : ''
|
||||
if (message) appendClientLog(level, source, message)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:save-snapshot')
|
||||
ipcMain.handle('workspace:save-snapshot', (_evt, key: unknown, data: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return
|
||||
saveDataSnapshot(key.trim(), data)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:get-dashboard')
|
||||
ipcMain.handle('workspace:get-dashboard', () => getDashboardSnapshot())
|
||||
|
||||
ipcMain.removeHandler('app:get-setting')
|
||||
ipcMain.handle('app:get-setting', (_evt, key: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return null
|
||||
return getAppSetting(key.trim())
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-setting')
|
||||
ipcMain.handle('app:set-setting', (_evt, key: unknown, value: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return
|
||||
if (typeof value !== 'string') return
|
||||
setAppSetting(key.trim(), value)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('relay:sync-config')
|
||||
ipcMain.handle('relay:sync-config', () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
syncRelayChannelsWithBackendConfig(path.join(br, 'config'))
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('relay:import-from-config')
|
||||
ipcMain.handle('relay:import-from-config', () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
importRelayChannelsFromConfigDir(path.join(br, 'config'))
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('easytier:get-peer-stats')
|
||||
ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats())
|
||||
|
||||
ipcMain.removeHandler('youtube-studio:ensure')
|
||||
ipcMain.handle('youtube-studio:ensure', (_evt, suffix: unknown) => {
|
||||
ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('open-external')
|
||||
ipcMain.handle('open-external', async (_evt, url: unknown) => {
|
||||
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
||||
throw new Error('无效的链接地址')
|
||||
}
|
||||
await shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('web2:get-api-token')
|
||||
ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null)
|
||||
|
||||
ipcMain.removeHandler('remote:get-status')
|
||||
ipcMain.handle('remote:get-status', () => {
|
||||
const bindHost = resolveWeb2Host()
|
||||
const et = getEasyTierRuntimeState()
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const easytierNetworkName = readEasyTierNetworkName(br)
|
||||
return {
|
||||
bindHost,
|
||||
remoteEnabled: bindHost === '0.0.0.0',
|
||||
port: PORT,
|
||||
hasToken: !!resolveWeb2ApiTokenForChild(),
|
||||
candidates: collectEasyTierCandidateIPs(),
|
||||
envHostOverride: !!process.env.WEB2_HOST?.trim(),
|
||||
easytierBundled: et.bundled,
|
||||
easytierRunning: et.running,
|
||||
easytierLastError: et.lastError,
|
||||
easytierNetworkName,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:get-bind-payload')
|
||||
ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => {
|
||||
if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfg = loadOrCreateNetworkConfig(br)
|
||||
const token = resolveWeb2ApiTokenForChild() ?? ''
|
||||
const ip = selectedIp.trim()
|
||||
return {
|
||||
v: 1 as const,
|
||||
baseUrl: `http://${ip}:${PORT}`,
|
||||
port: PORT,
|
||||
token,
|
||||
easytier: {
|
||||
networkName: cfg.networkName,
|
||||
networkSecret: cfg.networkSecret,
|
||||
peerUrls: cfg.peerUrls,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:set-enabled')
|
||||
ipcMain.handle('remote:set-enabled', async (_evt, enabled: unknown) => {
|
||||
if (process.env.WEB2_HOST?.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '已设置环境变量 WEB2_HOST,请改用环境变量或删除后再用界面开关。',
|
||||
}
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { ok: false, error: '参数无效' }
|
||||
}
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfgDir = path.join(br, 'config')
|
||||
fs.mkdirSync(cfgDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8')
|
||||
const r = await restartWeb2Backend()
|
||||
if (r.ok && enabled) startBundledEasyTier(br)
|
||||
return r
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:generate-token')
|
||||
ipcMain.handle('remote:generate-token', async () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfgDir = path.join(br, 'config')
|
||||
fs.mkdirSync(cfgDir, { recursive: true })
|
||||
const tok = crypto.randomBytes(24).toString('hex')
|
||||
fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8')
|
||||
const r = await restartWeb2Backend()
|
||||
if (r.ok) startBundledEasyTier(br)
|
||||
return r
|
||||
})
|
||||
|
||||
web2Host = resolveWeb2Host()
|
||||
|
||||
const backendRoot = getBackendRoot()
|
||||
const pythonExe = resolvePythonExe(backendRoot)
|
||||
_backendRoot = backendRoot
|
||||
_pythonExe = pythonExe
|
||||
|
||||
if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`未找到程序必要文件,当前安装可能不完整。\n\n路径:${backendRoot}\n\n开发调试:请在项目根目录保留 web2.py,并在 electron 目录执行 pnpm dev。`,
|
||||
)
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
startPythonBackend(backendRoot, pythonExe)
|
||||
|
||||
try {
|
||||
await waitForHealth()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
startBundledEasyTier(backendRoot)
|
||||
|
||||
appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
|
||||
|
||||
await createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
appendClientLog('info', 'main', '应用退出')
|
||||
flushClientDb()
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
})
|
||||
}
|
||||
120
electron/src/main/youtubeStudioSidecar.ts
Normal file
120
electron/src/main/youtubeStudioSidecar.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* YouTube Studio 直播后台侧窗:自动点击 Dismiss(对齐 GreasyFork「YouTube Studio Auto Dismiss」逻辑),
|
||||
* 并可检测页面文本是否包含串流密钥后缀以便与本地配置对应。
|
||||
*/
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
let studioWin: BrowserWindow | null = null
|
||||
|
||||
const STUDIO_ENTRY = 'https://studio.youtube.com/livestreaming'
|
||||
|
||||
/** 与油猴脚本等价的定时查找 Dismiss 按钮并点击(含 Shadow DOM) */
|
||||
const AUTO_DISMISS_JS = `
|
||||
(function(){
|
||||
if (window.__douyinRecorderYtDismiss) return;
|
||||
window.__douyinRecorderYtDismiss = true;
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
try {
|
||||
var s = window.getComputedStyle(el);
|
||||
return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function findDismiss(root) {
|
||||
root = root || document;
|
||||
var buttons = root.querySelectorAll('button');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
var el = buttons[i];
|
||||
try {
|
||||
var text = (el.innerText || '').trim();
|
||||
if (text === 'Dismiss' && isVisible(el)) return el;
|
||||
if (el.shadowRoot) {
|
||||
var x = findDismiss(el.shadowRoot);
|
||||
if (x) return x;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function clickEl(el) {
|
||||
if (!el) return;
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
function tick() {
|
||||
var b = findDismiss();
|
||||
if (b) clickEl(b);
|
||||
}
|
||||
if (document.readyState === 'complete') tick();
|
||||
else window.addEventListener('load', tick);
|
||||
setInterval(tick, 5000);
|
||||
})();
|
||||
true;
|
||||
`
|
||||
|
||||
const CHECK_KEY_JS = (suffix: string) => `
|
||||
(function(){
|
||||
var s = (${JSON.stringify(suffix)} || '').trim();
|
||||
if (!s) return { ok: true, hasSuffix: true };
|
||||
try {
|
||||
var t = document.body ? document.body.innerText : '';
|
||||
return { ok: true, hasSuffix: t.indexOf(s) >= 0 };
|
||||
} catch (e) {
|
||||
return { ok: false, hasSuffix: false };
|
||||
}
|
||||
})();
|
||||
`
|
||||
|
||||
export function ensureYoutubeStudioWindow(streamKeySuffix: string | undefined): void {
|
||||
const suffix = (streamKeySuffix || '').trim().slice(-12)
|
||||
|
||||
const inject = (wc: Electron.WebContents): void => {
|
||||
const u = wc.getURL()
|
||||
if (!u.includes('studio.youtube.com')) return
|
||||
void wc.executeJavaScript(AUTO_DISMISS_JS, true).catch(() => {})
|
||||
if (suffix) {
|
||||
void wc.executeJavaScript(CHECK_KEY_JS(suffix), true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (studioWin && !studioWin.isDestroyed()) {
|
||||
studioWin.show()
|
||||
studioWin.focus()
|
||||
const wc = studioWin.webContents
|
||||
const cur = wc.getURL()
|
||||
if (cur.includes('studio.youtube.com')) {
|
||||
inject(wc)
|
||||
} else {
|
||||
wc.once('did-finish-load', () => inject(wc))
|
||||
void wc.loadURL(STUDIO_ENTRY)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
studioWin = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 800,
|
||||
minHeight: 560,
|
||||
show: true,
|
||||
title: 'YouTube Studio 直播',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
/* 需登录 Google;sandbox 过严时可能导致 OAuth/ Cookie 异常 */
|
||||
sandbox: false,
|
||||
partition: 'persist:yt-studio',
|
||||
},
|
||||
})
|
||||
|
||||
if (app.isPackaged) {
|
||||
studioWin.removeMenu()
|
||||
}
|
||||
|
||||
const wc = studioWin.webContents
|
||||
wc.once('did-finish-load', () => inject(wc))
|
||||
void wc.loadURL(STUDIO_ENTRY)
|
||||
|
||||
studioWin.on('closed', () => {
|
||||
studioWin = null
|
||||
})
|
||||
}
|
||||
60
electron/src/preload/index.ts
Normal file
60
electron/src/preload/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('recorderConsole', {
|
||||
platform: process.platform,
|
||||
/** 沙箱下 preload 无可靠 shell,经主进程打开系统浏览器 */
|
||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('web2Remote', {
|
||||
getApiToken: (): Promise<string | null> => ipcRenderer.invoke('web2:get-api-token'),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('remoteBinding', {
|
||||
getStatus: () => ipcRenderer.invoke('remote:get-status'),
|
||||
setRemoteEnabled: (enabled: boolean) => ipcRenderer.invoke('remote:set-enabled', enabled),
|
||||
generateToken: () => ipcRenderer.invoke('remote:generate-token'),
|
||||
getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('workspace', {
|
||||
logLiveEvent: (payload: unknown) => ipcRenderer.invoke('workspace:log-live', payload),
|
||||
logUserAction: (payload: unknown) => ipcRenderer.invoke('workspace:log-user', payload),
|
||||
logClient: (payload: unknown) => ipcRenderer.invoke('workspace:log-client', payload),
|
||||
saveSnapshot: (key: string, data: unknown) => ipcRenderer.invoke('workspace:save-snapshot', key, data),
|
||||
getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'),
|
||||
})
|
||||
|
||||
/** 工作台 EasyTier / YouTube Studio 侧窗(无 preload 的独立窗口不暴露此 API) */
|
||||
contextBridge.exposeInMainWorld('deskBridge', {
|
||||
getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'),
|
||||
ensureYoutubeStudio: (streamKeySuffix?: string) =>
|
||||
ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('appTheme', {
|
||||
get: () => ipcRenderer.invoke('app:get-setting', 'theme'),
|
||||
set: (value: string) => ipcRenderer.invoke('app:set-setting', 'theme', value),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('relayConfig', {
|
||||
syncConfig: () => ipcRenderer.invoke('relay:sync-config'),
|
||||
importFromConfig: () => ipcRenderer.invoke('relay:import-from-config'),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('appHost', {
|
||||
onNavigate: (cb: (nav: string) => void) => {
|
||||
const listener = (_e: unknown, nav: string) => cb(nav)
|
||||
ipcRenderer.on('app:nav', listener)
|
||||
return () => ipcRenderer.removeListener('app:nav', listener)
|
||||
},
|
||||
getLoginItemSettings: () => ipcRenderer.invoke('app:get-login-item'),
|
||||
setLoginItemOpenAtLogin: (v: boolean) => ipcRenderer.invoke('app:set-login-item', v),
|
||||
relaunch: () => ipcRenderer.invoke('app:relaunch'),
|
||||
getMetrics: () => ipcRenderer.invoke('app:get-metrics'),
|
||||
showAbout: () => ipcRenderer.invoke('app:show-about'),
|
||||
setBadge: (n: number) => ipcRenderer.invoke('app:set-badge', n),
|
||||
getPaths: () => ipcRenderer.invoke('app:get-paths'),
|
||||
addRecentConfig: () => ipcRenderer.invoke('app:add-recent-config'),
|
||||
openLogsFolder: () => ipcRenderer.invoke('app:open-logs-folder'),
|
||||
})
|
||||
19
electron/src/renderer/index.html
Normal file
19
electron/src/renderer/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>直播录制助手</title>
|
||||
<script>
|
||||
try {
|
||||
var t = localStorage.getItem('theme')
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t)
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2313
electron/src/renderer/src/App.css
Normal file
2313
electron/src/renderer/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
1137
electron/src/renderer/src/App.tsx
Normal file
1137
electron/src/renderer/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
226
electron/src/renderer/src/AppHostControls.tsx
Normal file
226
electron/src/renderer/src/AppHostControls.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type LoginItem = {
|
||||
openAtLogin?: boolean
|
||||
executableWillLaunchAtLogin?: boolean
|
||||
}
|
||||
|
||||
type PathInfo = {
|
||||
userData: string
|
||||
logs: string
|
||||
temp: string
|
||||
backendRoot: string
|
||||
backendConfig: string
|
||||
}
|
||||
|
||||
type ProcessMetric = {
|
||||
pid?: number
|
||||
type?: string
|
||||
name?: string
|
||||
cpu?: { percentUsage?: number }
|
||||
memory?: number
|
||||
}
|
||||
|
||||
export default function AppHostControls() {
|
||||
const host = typeof window !== 'undefined' ? window.appHost : undefined
|
||||
const [login, setLogin] = useState<LoginItem | null>(null)
|
||||
const [paths, setPaths] = useState<PathInfo | null>(null)
|
||||
const [metrics, setMetrics] = useState<ProcessMetric[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [badgeDraft, setBadgeDraft] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!host?.getPaths) return
|
||||
setErr(null)
|
||||
try {
|
||||
const [li, p, m] = await Promise.all([
|
||||
host.getLoginItemSettings?.() ?? Promise.resolve(null),
|
||||
host.getPaths(),
|
||||
host.getMetrics?.() ?? Promise.resolve([]),
|
||||
])
|
||||
setLogin((li as LoginItem) ?? null)
|
||||
setPaths(p as PathInfo)
|
||||
setMetrics(Array.isArray(m) ? (m as ProcessMetric[]) : [])
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [host])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), 8000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
if (!host?.getPaths) {
|
||||
return (
|
||||
<section className="card app-host-card" aria-labelledby="app-host-title">
|
||||
<h2 className="card__title" id="app-host-title">
|
||||
客户端与系统集成
|
||||
</h2>
|
||||
<p className="card__desc">此区域仅在 Electron 桌面客户端可用。</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const openAt =
|
||||
typeof login?.openAtLogin === 'boolean'
|
||||
? login.openAtLogin
|
||||
: Boolean(login?.executableWillLaunchAtLogin)
|
||||
|
||||
return (
|
||||
<section className="card app-host-card" aria-labelledby="app-host-title">
|
||||
<h2 className="card__title" id="app-host-title">
|
||||
客户端与系统集成
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
基于 Electron <code className="remote-code">app</code>:开机启动、任务栏快捷、关于面板、日志目录、进程指标等。
|
||||
</p>
|
||||
{err && (
|
||||
<p className="remote-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="app-host-grid">
|
||||
<div className="app-host-block">
|
||||
<div className="app-host-block__title">开机启动</div>
|
||||
<label className="app-host-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={openAt}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked
|
||||
setLoading(true)
|
||||
void (host.setLoginItemOpenAtLogin?.(v) ?? Promise.resolve())
|
||||
.then((r) => {
|
||||
if (r && typeof r === 'object' && 'ok' in r && (r as { ok: boolean }).ok === false) {
|
||||
setErr((r as { error?: string }).error ?? '无法设置开机启动')
|
||||
} else {
|
||||
setErr(null)
|
||||
}
|
||||
void load()
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}}
|
||||
/>
|
||||
<span>登录系统后自动打开本应用(无人值守机可勾选)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="app-host-block">
|
||||
<div className="app-host-block__title">快捷操作</div>
|
||||
<div className="app-host-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void host.showAbout?.()}>
|
||||
关于本应用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
if (window.confirm('将重启整个客户端并重新加载本地服务,确定?')) void host.relaunch?.()
|
||||
}}
|
||||
>
|
||||
重启应用
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void host.openLogsFolder?.()}>
|
||||
打开日志目录
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void host.addRecentConfig?.()}>
|
||||
将配置目录加入「最近使用」
|
||||
</button>
|
||||
</div>
|
||||
<p className="app-host-hint">
|
||||
Windows:固定到任务栏后,可右键图标使用「工作台 / 录制 / 网络」快捷任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="app-host-block">
|
||||
<div className="app-host-block__title">Dock 角标(macOS / 部分 Linux)</div>
|
||||
<div className="app-host-badge-row">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
className="app-host-badge-input"
|
||||
value={badgeDraft}
|
||||
onChange={(e) => setBadgeDraft(Number(e.target.value) || 0)}
|
||||
aria-label="角标数字"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void host.setBadge?.(badgeDraft)}
|
||||
>
|
||||
应用角标
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void host.setBadge?.(0)}>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<p className="app-host-hint">Windows 无 Dock 角标;可与后续「断流告警」计数联动。</p>
|
||||
</div>
|
||||
|
||||
<div className="app-host-block">
|
||||
<div className="app-host-block__title">路径(便于打包日志给技术支持)</div>
|
||||
{paths && (
|
||||
<ul className="app-host-paths">
|
||||
<li>
|
||||
<span className="app-host-paths__k">用户数据</span>
|
||||
<code className="app-host-paths__v">{paths.userData}</code>
|
||||
</li>
|
||||
<li>
|
||||
<span className="app-host-paths__k">日志</span>
|
||||
<code className="app-host-paths__v">{paths.logs}</code>
|
||||
</li>
|
||||
<li>
|
||||
<span className="app-host-paths__k">后端根目录</span>
|
||||
<code className="app-host-paths__v">{paths.backendRoot}</code>
|
||||
</li>
|
||||
<li>
|
||||
<span className="app-host-paths__k">config</span>
|
||||
<code className="app-host-paths__v">{paths.backendConfig}</code>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-host-block app-host-block--wide">
|
||||
<div className="app-host-block__title">Electron 进程指标(app.getAppMetrics)</div>
|
||||
{metrics.length > 0 ? (
|
||||
<div className="metrics-table-wrap">
|
||||
<table className="metrics-table app-host-metrics">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>PID</th>
|
||||
<th>名称</th>
|
||||
<th>CPU</th>
|
||||
<th>内存</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map((row, i) => (
|
||||
<tr key={`${row.pid ?? i}-${row.type ?? ''}`}>
|
||||
<td>{row.type ?? '—'}</td>
|
||||
<td>{row.pid ?? '—'}</td>
|
||||
<td>{row.name ?? '—'}</td>
|
||||
<td>
|
||||
{row.cpu?.percentUsage != null ? `${row.cpu.percentUsage.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td>{row.memory != null ? `${(row.memory / 1024 / 1024).toFixed(1)} MB` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="card__desc">暂无数据</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
233
electron/src/renderer/src/NetworkDashboard.tsx
Normal file
233
electron/src/renderer/src/NetworkDashboard.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiFetch, apiUrl } from './api'
|
||||
import { saveWorkspaceSnapshot } from './workspace'
|
||||
|
||||
type SiteInfo = {
|
||||
host: string
|
||||
reachable: boolean
|
||||
latency_ms: number
|
||||
error: string | null
|
||||
resolved_ip: string | null
|
||||
country: string | null
|
||||
country_code: string | null
|
||||
geo_error: string | null
|
||||
}
|
||||
|
||||
type SpeedBlock = {
|
||||
label: string
|
||||
download_mbps: number | null
|
||||
download_error: string | null
|
||||
download_bytes_sampled: number
|
||||
upload_mbps: number | null
|
||||
upload_error: string | null
|
||||
upload_target: string | null
|
||||
}
|
||||
|
||||
type NetworkPayload = {
|
||||
google: SiteInfo
|
||||
douyin: SiteInfo
|
||||
routes: { different: boolean | null; note: string }
|
||||
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
|
||||
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
const EMPTY_SITE: SiteInfo = {
|
||||
host: '',
|
||||
reachable: false,
|
||||
latency_ms: -1,
|
||||
error: null,
|
||||
resolved_ip: null,
|
||||
country: null,
|
||||
country_code: null,
|
||||
geo_error: null,
|
||||
}
|
||||
|
||||
const EMPTY_SPEED: SpeedBlock = {
|
||||
label: '',
|
||||
download_mbps: null,
|
||||
download_error: null,
|
||||
download_bytes_sampled: 0,
|
||||
upload_mbps: null,
|
||||
upload_error: null,
|
||||
upload_target: null,
|
||||
}
|
||||
|
||||
const DEFAULT_NETWORK: NetworkPayload = {
|
||||
google: EMPTY_SITE,
|
||||
douyin: EMPTY_SITE,
|
||||
routes: { different: null, note: '' },
|
||||
speed: {
|
||||
overseas: EMPTY_SPEED,
|
||||
domestic: EMPTY_SPEED,
|
||||
},
|
||||
}
|
||||
|
||||
function fmtMbps(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return `${v.toFixed(2)} Mbps`
|
||||
}
|
||||
|
||||
function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
|
||||
const ok = site.reachable
|
||||
return (
|
||||
<div className="net-site">
|
||||
<div className="net-site__head">
|
||||
<span className="net-site__title">{title}</span>
|
||||
<span className={`net-badge ${ok ? 'net-badge--ok' : 'net-badge--bad'}`}>
|
||||
{ok ? '可访问' : '不可访问'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="net-site__grid">
|
||||
<span className="net-k">延迟</span>
|
||||
<span className="net-v">
|
||||
{site.latency_ms != null && site.latency_ms >= 0 ? `${site.latency_ms.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
<span className="net-k">解析 IP</span>
|
||||
<span className="net-v net-v--mono">{site.resolved_ip ?? '—'}</span>
|
||||
<span className="net-k">地区(参考)</span>
|
||||
<span className="net-v">
|
||||
{site.country ?? '—'}
|
||||
{site.country_code ? ` (${site.country_code})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{(site.error || site.geo_error) && (
|
||||
<p className="net-site__err">
|
||||
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetworkDashboard() {
|
||||
const [data, setData] = useState<NetworkPayload | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await apiFetch(apiUrl('/network_status'))
|
||||
const json = (await res.json()) as NetworkPayload & { error?: string }
|
||||
if (!res.ok) {
|
||||
setErr(json.error || `请求失败 ${res.status}`)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setData(json)
|
||||
saveWorkspaceSnapshot('network_status', json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setData(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => void load(), 45000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const view = data ?? DEFAULT_NETWORK
|
||||
|
||||
return (
|
||||
<section className="card card--network" aria-labelledby="net-dash-title">
|
||||
<div className="net-dash__header">
|
||||
<div>
|
||||
<h2 className="card__title" id="net-dash-title">
|
||||
网络仪表盘
|
||||
</h2>
|
||||
<p className="card__desc net-dash__lead">
|
||||
检测 Google(偏海外)与抖音(偏国内)是否通畅、解析出口是否像两条线,并估算上下行(采样约 0.6MB
|
||||
下行 / 256KB 上行)。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary net-dash__refresh" onClick={() => void load()}>
|
||||
重新检测
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<p className="net-dash__error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<>
|
||||
<div className="net-site-row">
|
||||
<SiteRow title="Google(海外参考)" site={view.google} />
|
||||
<SiteRow title="抖音(国内参考)" site={view.douyin} />
|
||||
</div>
|
||||
|
||||
<div className="net-routes">
|
||||
<span className="net-routes__label">线路是否像「两条线」</span>
|
||||
<span
|
||||
className={`net-routes__pill ${
|
||||
view.routes.different === true
|
||||
? 'net-routes__pill--yes'
|
||||
: view.routes.different === false
|
||||
? 'net-routes__pill--no'
|
||||
: 'net-routes__pill--unk'
|
||||
}`}
|
||||
>
|
||||
{view.routes.different === true
|
||||
? '是(解析国家不同)'
|
||||
: view.routes.different === false
|
||||
? '否或不确定'
|
||||
: '未知'}
|
||||
</span>
|
||||
<p className="net-routes__note">{view.routes.note}</p>
|
||||
</div>
|
||||
|
||||
<div className="net-speed">
|
||||
<h3 className="net-speed__title">网速(估算)</h3>
|
||||
<div className="net-speed__grid">
|
||||
<div className="net-speed__col">
|
||||
<div className="net-speed__head">海外</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">下行</span>
|
||||
<span className="net-metric__v">{fmtMbps(view.speed.overseas.download_mbps)}</span>
|
||||
</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">上行</span>
|
||||
<span className="net-metric__v">{fmtMbps(view.speed.overseas.upload_mbps)}</span>
|
||||
</div>
|
||||
{(view.speed.overseas.download_error || view.speed.overseas.upload_error) && (
|
||||
<p className="net-speed__hint">
|
||||
{[view.speed.overseas.download_error, view.speed.overseas.upload_error]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="net-speed__col">
|
||||
<div className="net-speed__head">国内</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">下行</span>
|
||||
<span className="net-metric__v">{fmtMbps(view.speed.domestic.download_mbps)}</span>
|
||||
</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">上行</span>
|
||||
<span className="net-metric__v">{fmtMbps(view.speed.domestic.upload_mbps)}</span>
|
||||
</div>
|
||||
{(view.speed.domestic.download_error || view.speed.domestic.upload_error) && (
|
||||
<p className="net-speed__hint">
|
||||
{[view.speed.domestic.download_error, view.speed.domestic.upload_error]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="net-speed__foot">
|
||||
上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户
|
||||
POST。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
104
electron/src/renderer/src/ProxySettings.tsx
Normal file
104
electron/src/renderer/src/ProxySettings.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiFetch, apiUrl } from './api'
|
||||
|
||||
type ProxyCfg = { enabled: boolean; http: string; socks: string }
|
||||
|
||||
export default function ProxySettings() {
|
||||
const [cfg, setCfg] = useState<ProxyCfg>({ enabled: false, http: '', socks: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setMsg('')
|
||||
try {
|
||||
const res = await apiFetch(apiUrl('/proxy_config'))
|
||||
const data = (await res.json()) as ProxyCfg
|
||||
if (!res.ok) throw new Error('读取失败')
|
||||
setCfg({
|
||||
enabled: !!data.enabled,
|
||||
http: data.http || '',
|
||||
socks: data.socks || '',
|
||||
})
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const res = await apiFetch(apiUrl('/proxy_config'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存失败')
|
||||
setMsg('已保存。重启录制任务后生效。')
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card--proxy" aria-labelledby="proxy-settings-title">
|
||||
<h2 className="card__title" id="proxy-settings-title">
|
||||
录制专用代理(不改系统网络)
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改 Windows
|
||||
系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。
|
||||
YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。
|
||||
</p>
|
||||
<div className="proxy-form">
|
||||
<label className="proxy-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cfg.enabled}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span>启用仅录制进程使用的代理</span>
|
||||
</label>
|
||||
<label className="field-label" htmlFor="proxyHttp">
|
||||
HTTP 代理(供 FFmpeg -http_proxy 等)
|
||||
</label>
|
||||
<input
|
||||
id="proxyHttp"
|
||||
className="proxy-input"
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={cfg.http}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, http: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label className="field-label" htmlFor="proxySocks">
|
||||
SOCKS5(写入 ALL_PROXY,部分连接可能走此)
|
||||
</label>
|
||||
<input
|
||||
id="proxySocks"
|
||||
className="proxy-input"
|
||||
type="text"
|
||||
placeholder="socks5://127.0.0.1:7890"
|
||||
value={cfg.socks}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, socks: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
|
||||
重新读取
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className="proxy-form__msg">{msg}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
319
electron/src/renderer/src/RemoteBinding.tsx
Normal file
319
electron/src/renderer/src/RemoteBinding.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { initApiFromMain } from './api'
|
||||
|
||||
type RemoteStatus = {
|
||||
bindHost: string
|
||||
remoteEnabled: boolean
|
||||
port: number
|
||||
hasToken: boolean
|
||||
candidates: { address: string; name: string }[]
|
||||
envHostOverride: boolean
|
||||
easytierBundled: boolean
|
||||
easytierRunning: boolean
|
||||
easytierLastError: string
|
||||
easytierNetworkName: string
|
||||
}
|
||||
|
||||
type RemoteBindingApi = {
|
||||
getStatus: () => Promise<RemoteStatus>
|
||||
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
/** React Native 扫码:JSON 含 baseUrl、token、easytier(组网名/密钥/可选 peer) */
|
||||
export default function RemoteBinding() {
|
||||
const rb = (typeof window !== 'undefined'
|
||||
? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
|
||||
: undefined) as RemoteBindingApi | undefined
|
||||
|
||||
const [status, setStatus] = useState<RemoteStatus | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [selectedIp, setSelectedIp] = useState('')
|
||||
const [manualHost, setManualHost] = useState('')
|
||||
const [qrImg, setQrImg] = useState<string | null>(null)
|
||||
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
const pickBindIp = useCallback((): string => {
|
||||
if (!status?.candidates?.length) return manualHost.trim()
|
||||
if (selectedIp.trim()) return selectedIp.trim()
|
||||
return manualHost.trim()
|
||||
}, [status?.candidates?.length, selectedIp, manualHost])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!rb?.getStatus) {
|
||||
setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const s = await rb.getStatus()
|
||||
setStatus(s)
|
||||
setMsg('')
|
||||
} catch (e) {
|
||||
setStatus(null)
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [rb])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.candidates?.length) return
|
||||
setSelectedIp((prev) => {
|
||||
if (prev && status.candidates.some((c) => c.address === prev)) return prev
|
||||
return status.candidates[0].address
|
||||
})
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.remoteEnabled || !rb?.getBindPayload) {
|
||||
setBindPayload(null)
|
||||
return
|
||||
}
|
||||
const ip = pickBindIp()
|
||||
if (!ip) {
|
||||
setBindPayload(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void rb.getBindPayload(ip).then((p) => {
|
||||
if (!cancelled) setBindPayload(p)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status?.remoteEnabled, rb, status?.hasToken, pickBindIp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bindPayload) {
|
||||
setQrImg(null)
|
||||
return
|
||||
}
|
||||
const json = JSON.stringify(bindPayload)
|
||||
void QRCode.toDataURL(json, { margin: 2, width: 224 }).then(setQrImg).catch(() => setQrImg(null))
|
||||
}, [bindPayload])
|
||||
|
||||
const onToggle = async (enabled: boolean) => {
|
||||
if (!rb?.setRemoteEnabled) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await rb.setRemoteEnabled(enabled)
|
||||
if (!r.ok) {
|
||||
setMsg(r.error)
|
||||
return
|
||||
}
|
||||
await initApiFromMain()
|
||||
await load()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onGenerateToken = async () => {
|
||||
if (!rb?.generateToken) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await rb.generateToken()
|
||||
if (!r.ok) {
|
||||
setMsg(r.error)
|
||||
return
|
||||
}
|
||||
await initApiFromMain()
|
||||
await load()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onGenerateQr = async () => {
|
||||
if (!rb?.getBindPayload) return
|
||||
if (!status?.remoteEnabled) {
|
||||
setMsg('请先勾选「允许局域网 / 虚拟网访问」并等待后端重启完成。')
|
||||
return
|
||||
}
|
||||
const ip = pickBindIp()
|
||||
if (!ip) {
|
||||
const hasList = !!status.candidates?.length
|
||||
setMsg(
|
||||
hasList
|
||||
? '请从上方下拉选择本机 IP,或在「手动 IP」中填写手机能访问的地址。'
|
||||
: '未检测到本机局域网 IP:请在下方「手动 IP」中输入(例如 192.168.x.x 或与手机同网段地址、EasyTier 虚拟 IP),或点「刷新 IP 列表」后重试。',
|
||||
)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const p = await rb.getBindPayload(ip)
|
||||
setBindPayload(p)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rb?.getStatus) {
|
||||
return (
|
||||
<section className="card card--remote">
|
||||
<h2 className="card__title">远程控制与扫码绑定</h2>
|
||||
<p className="card__desc">{msg || '不可用'}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const s = status
|
||||
|
||||
return (
|
||||
<section className="card card--remote" aria-labelledby="remote-binding-title">
|
||||
<h2 className="card__title" id="remote-binding-title">
|
||||
远程控制与扫码绑定
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
Windows 安装包已内置{' '}
|
||||
<a href="https://github.com/EasyTier/EasyTier" target="_blank" rel="noreferrer" className="remote-link">
|
||||
EasyTier
|
||||
</a>{' '}
|
||||
核心进程,启动后自动组网(无需再单独安装)。手机端 App 请使用同项目内置的 EasyTier 与扫码 JSON 中的{' '}
|
||||
<code className="remote-code">easytier</code> 字段完成配对。开启「允许远程」后监听 0.0.0.0,请务必生成 API 密钥。
|
||||
</p>
|
||||
|
||||
<div className="remote-et-status" role="status">
|
||||
<span className={`remote-et-pill ${s?.easytierBundled ? 'on' : 'off'}`}>
|
||||
{s?.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier)'}
|
||||
</span>
|
||||
{s?.easytierBundled && (
|
||||
<span className={`remote-et-pill ${s.easytierRunning ? 'on' : 'warn'}`}>
|
||||
{s.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
|
||||
</span>
|
||||
)}
|
||||
{!!s?.easytierNetworkName && (
|
||||
<span className="remote-et-meta">组网名:{s.easytierNetworkName}</span>
|
||||
)}
|
||||
</div>
|
||||
{s?.easytierBundled && s.easytierLastError && (
|
||||
<p className="remote-warn" role="status">
|
||||
EasyTier:{s.easytierLastError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.envHostOverride && (
|
||||
<p className="remote-warn" role="status">
|
||||
当前由环境变量 WEB2_HOST 覆盖绑定地址,界面开关已禁用。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="remote-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="remote-toggle-input"
|
||||
checked={!!status?.remoteEnabled}
|
||||
disabled={busy || !!status?.envHostOverride}
|
||||
onChange={(e) => void onToggle(e.target.checked)}
|
||||
/>
|
||||
<span>允许局域网 / 虚拟网访问(监听 0.0.0.0)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void onGenerateToken()}>
|
||||
生成 API 密钥
|
||||
</button>
|
||||
<span className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}>
|
||||
{status?.hasToken ? '已配置密钥' : '未配置密钥(远程时强烈建议)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="remoteEtIp">
|
||||
本机候选 IP(虚拟网 / 局域网)
|
||||
</label>
|
||||
<select
|
||||
id="remoteEtIp"
|
||||
className="select-process"
|
||||
value={selectedIp}
|
||||
onChange={(e) => setSelectedIp(e.target.value)}
|
||||
disabled={!status?.candidates?.length}
|
||||
>
|
||||
{(status?.candidates?.length
|
||||
? status.candidates
|
||||
: [{ address: '', name: '(暂无候选,请用下方手动 IP 或点刷新)' }]
|
||||
).map((c) => (
|
||||
<option key={c.address || 'none'} value={c.address} disabled={!c.address}>
|
||||
{c.address ? `${c.address} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void load()}>
|
||||
刷新 IP 列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="remoteManualIp">
|
||||
手动 IP(无下拉或地址不对时必填)
|
||||
</label>
|
||||
<input
|
||||
id="remoteManualIp"
|
||||
type="text"
|
||||
className="select-process"
|
||||
placeholder="例如 192.168.1.10、10.126.126.1"
|
||||
value={manualHost}
|
||||
onChange={(e) => setManualHost(e.target.value)}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void onGenerateQr()}>
|
||||
生成绑定二维码
|
||||
</button>
|
||||
<span className="remote-qr-hint">供 React Native 扫码;需已开启「允许远程」并填写可访问的 IP。</span>
|
||||
</div>
|
||||
|
||||
{!s?.remoteEnabled && (
|
||||
<p className="remote-qr-hint">勾选「允许远程」并生效后,虚拟网内手机可访问本机 API 并扫码绑定。</p>
|
||||
)}
|
||||
|
||||
{s?.remoteEnabled && bindPayload && qrImg && (
|
||||
<div className="remote-qr-block">
|
||||
<p className="remote-qr-hint">
|
||||
React Native 扫码解析 JSON:含 <code className="remote-code">baseUrl</code>、<code className="remote-code">token</code>、
|
||||
<code className="remote-code">easytier</code>(与内置客户端使用相同组网名与密钥)。勿外传。
|
||||
</p>
|
||||
<img src={qrImg} alt="绑定信息二维码" className="remote-qr-img" width={224} height={224} />
|
||||
<pre className="remote-qr-json" tabIndex={0}>
|
||||
{JSON.stringify(bindPayload, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void navigator.clipboard.writeText(JSON.stringify(bindPayload))}
|
||||
>
|
||||
复制 JSON
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.remoteEnabled && !bindPayload && (
|
||||
<p className="remote-qr-hint">
|
||||
尚未生成绑定数据:在下拉中选局域网 IP,或在「手动 IP」填写后点「生成绑定二维码」;亦可点「刷新 IP 列表」更新网卡地址。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p className="remote-error" role="alert">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
147
electron/src/renderer/src/SystemMetrics.tsx
Normal file
147
electron/src/renderer/src/SystemMetrics.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiFetch, apiUrl } from './api'
|
||||
import { saveWorkspaceSnapshot } from './workspace'
|
||||
import AppHostControls from './AppHostControls'
|
||||
|
||||
type Metrics = {
|
||||
ts?: number
|
||||
cpu_percent?: number | null
|
||||
cpu_count_logical?: number
|
||||
memory?: { percent?: number; used?: number; total?: number; available?: number }
|
||||
swap?: { percent?: number; used?: number; total?: number }
|
||||
disks?: Array<{
|
||||
mountpoint?: string
|
||||
percent?: number
|
||||
free?: number
|
||||
total?: number
|
||||
}>
|
||||
network?: { up_mbps?: number | null; down_mbps?: number | null }
|
||||
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
|
||||
error?: string
|
||||
}
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n == null || Number.isNaN(n)) return '—'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export default function SystemMetrics() {
|
||||
const [data, setData] = useState<Metrics | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch(apiUrl('/system_metrics'))
|
||||
const json = (await res.json()) as Metrics
|
||||
if (!res.ok) {
|
||||
setErr(json.error || `HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
setErr(null)
|
||||
setData(json)
|
||||
saveWorkspaceSnapshot('system_metrics', json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const m = data?.memory
|
||||
const net = data?.network
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card card--metrics" aria-labelledby="sys-metrics-title">
|
||||
<h2 className="card__title" id="sys-metrics-title">
|
||||
本机负载与网速
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
网速为全机所有网卡合计的估算值(首次刷新可能无速率,约 2 秒后出现)。下方列出当前检测到的
|
||||
ffmpeg 进程。
|
||||
</p>
|
||||
{err && (
|
||||
<p className="net-dash__error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
<div className="metrics-grid">
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">CPU</div>
|
||||
<div className="metrics-big">
|
||||
{data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className="metrics-sub">逻辑处理器 {data?.cpu_count_logical ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">内存</div>
|
||||
<div className="metrics-big">{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</div>
|
||||
<div className="metrics-sub">
|
||||
{fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">全机网速</div>
|
||||
<div className="metrics-rate">
|
||||
<span>↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}</span>
|
||||
<span>↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}</span>
|
||||
</div>
|
||||
<div className="metrics-sub">含所有网卡收发合计</div>
|
||||
</div>
|
||||
</div>
|
||||
{data?.swap && data.swap.total != null && data.swap.total > 0 && (
|
||||
<div className="metrics-swap">
|
||||
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
|
||||
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
|
||||
</div>
|
||||
)}
|
||||
{data?.disks && data.disks.length > 0 && (
|
||||
<div className="metrics-disks">
|
||||
<div className="metrics-disks__title">磁盘空间</div>
|
||||
<ul className="metrics-disk-list">
|
||||
{data.disks.slice(0, 8).map((d) => (
|
||||
<li key={d.mountpoint}>
|
||||
<span className="metrics-disk-list__mp">{d.mountpoint}</span>
|
||||
<span>
|
||||
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 && (
|
||||
<div className="metrics-ff">
|
||||
<div className="metrics-ff__title">ffmpeg 进程</div>
|
||||
<table className="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>名称</th>
|
||||
<th>内存</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.ffmpeg_processes.map((p) => (
|
||||
<tr key={p.pid}>
|
||||
<td>{p.pid}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>{fmtBytes(p.rss)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<AppHostControls />
|
||||
</>
|
||||
)
|
||||
}
|
||||
284
electron/src/renderer/src/WeChatChatbot.tsx
Normal file
284
electron/src/renderer/src/WeChatChatbot.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiFetch, apiUrl } from './api'
|
||||
|
||||
type WechatConfigView = {
|
||||
configured: boolean
|
||||
proxyUrl: string
|
||||
apiKeyMasked: string
|
||||
deviceType: string
|
||||
proxy: string
|
||||
webhookHost: string
|
||||
webhookPort: number
|
||||
webhookPath: string
|
||||
accountId: string
|
||||
session: { wcId?: string; nickName?: string }
|
||||
}
|
||||
|
||||
export default function WeChatChatbot() {
|
||||
const [cfg, setCfg] = useState<WechatConfigView | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [apiKeyInput, setApiKeyInput] = useState('')
|
||||
const [proxyUrlInput, setProxyUrlInput] = useState('')
|
||||
const [deviceType, setDeviceType] = useState('ipad')
|
||||
const [proxyLine, setProxyLine] = useState('10')
|
||||
|
||||
const [wId, setWId] = useState('')
|
||||
const [qrUrl, setQrUrl] = useState('')
|
||||
const [poll, setPoll] = useState(false)
|
||||
const [proxyStatus, setProxyStatus] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/chatbot/wechat/config'))
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
const j = (await r.json()) as WechatConfigView
|
||||
setCfg(j)
|
||||
setProxyUrlInput(j.proxyUrl || '')
|
||||
setDeviceType(j.deviceType || 'ipad')
|
||||
setProxyLine(j.proxy || '10')
|
||||
setApiKeyInput('')
|
||||
} catch (e) {
|
||||
setCfg(null)
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const saveConfig = async () => {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const body: Record<string, string | number> = {
|
||||
proxyUrl: proxyUrlInput.trim(),
|
||||
deviceType,
|
||||
proxy: proxyLine.trim(),
|
||||
}
|
||||
if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
|
||||
const r = await apiFetch(apiUrl('/chatbot/wechat/config'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const t = await r.text()
|
||||
throw new Error(t || String(r.status))
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProxyStatus = async () => {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/chatbot/wechat/proxy/status'))
|
||||
const j = await r.json()
|
||||
if (!r.ok) {
|
||||
setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
|
||||
setProxyStatus(null)
|
||||
return
|
||||
}
|
||||
setProxyStatus(j as Record<string, unknown>)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startQr = async () => {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setQrUrl('')
|
||||
setWId('')
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/chatbot/wechat/qr/start'), { method: 'POST' })
|
||||
const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
|
||||
if (!r.ok) {
|
||||
setMsg(j.error || String(r.status))
|
||||
return
|
||||
}
|
||||
if (!j.wId || !j.qrCodeUrl) {
|
||||
setMsg('未返回二维码')
|
||||
return
|
||||
}
|
||||
setWId(j.wId)
|
||||
setQrUrl(j.qrCodeUrl)
|
||||
setPoll(true)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!poll || !wId) return
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/chatbot/wechat/login/check'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wId }),
|
||||
})
|
||||
const j = (await r.json()) as {
|
||||
status?: string
|
||||
wcId?: string
|
||||
nickName?: string
|
||||
verifyUrl?: string
|
||||
error?: string
|
||||
}
|
||||
if (!r.ok) {
|
||||
setMsg(j.error || String(r.status))
|
||||
setPoll(false)
|
||||
return
|
||||
}
|
||||
if (j.status === 'logged_in') {
|
||||
setPoll(false)
|
||||
setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`)
|
||||
void load()
|
||||
return
|
||||
}
|
||||
if (j.status === 'need_verify' && j.verifyUrl) {
|
||||
setMsg(`需要辅助验证:${j.verifyUrl}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
setPoll(false)
|
||||
}
|
||||
}
|
||||
const id = window.setInterval(() => void tick(), 5000)
|
||||
void tick()
|
||||
return () => window.clearInterval(id)
|
||||
}, [poll, wId, load])
|
||||
|
||||
return (
|
||||
<section className="card card--remote" aria-labelledby="wechat-title">
|
||||
<h2 className="card__title" id="wechat-title">
|
||||
微信 Chatbot
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
与 <code className="remote-code">openclaw-wechat</code> 使用同一类代理 API(需自备 apiKey 与 proxyUrl)。保存配置后可在下方获取二维码并用微信扫码登录;亦可用{' '}
|
||||
<code className="remote-code">wechat_chatbot_cli.py</code> 供脚本与 Agent 调用。
|
||||
</p>
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="wcProxyUrl">
|
||||
proxyUrl(代理服务根地址)
|
||||
</label>
|
||||
<input
|
||||
id="wcProxyUrl"
|
||||
className="select-process"
|
||||
value={proxyUrlInput}
|
||||
onChange={(e) => setProxyUrlInput(e.target.value)}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
placeholder="http://your-proxy:3000"
|
||||
/>
|
||||
</div>
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="wcApiKey">
|
||||
apiKey(留空则不修改已保存密钥)
|
||||
</label>
|
||||
<input
|
||||
id="wcApiKey"
|
||||
type="password"
|
||||
className="select-process"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
|
||||
/>
|
||||
</div>
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="wcDev">
|
||||
deviceType
|
||||
</label>
|
||||
<select
|
||||
id="wcDev"
|
||||
className="select-process"
|
||||
value={deviceType}
|
||||
onChange={(e) => setDeviceType(e.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="ipad">ipad</option>
|
||||
<option value="mac">mac</option>
|
||||
</select>
|
||||
<label className="field-label" htmlFor="wcPx">
|
||||
proxy 参数
|
||||
</label>
|
||||
<input
|
||||
id="wcPx"
|
||||
className="select-process wc-proxy-param"
|
||||
value={proxyLine}
|
||||
onChange={(e) => setProxyLine(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void saveConfig()}>
|
||||
保存配置
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void loadProxyStatus()}>
|
||||
查询代理状态
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{proxyStatus && (
|
||||
<pre className="remote-qr-json" tabIndex={0}>
|
||||
{JSON.stringify(proxyStatus, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{cfg?.session?.wcId && (
|
||||
<p className="remote-qr-hint">
|
||||
本地记录:{cfg.session.nickName || '—'}({cfg.session.wcId})
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-primary" disabled={busy || !cfg?.configured} onClick={() => void startQr()}>
|
||||
获取登录二维码
|
||||
</button>
|
||||
{!cfg?.configured && (
|
||||
<span className="remote-qr-hint">请先填写并保存 apiKey 与 proxyUrl(参考 config/wechat_chatbot.example.json)。</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{qrUrl && (
|
||||
<div className="remote-qr-block">
|
||||
<p className="remote-qr-hint">
|
||||
请使用微信扫描下方二维码。若图片无法显示,请在新标签页打开二维码链接。
|
||||
</p>
|
||||
<img src={qrUrl} alt="微信登录二维码" className="remote-qr-img" width={224} height={224} />
|
||||
<p className="remote-qr-hint">
|
||||
<a href={qrUrl} target="_blank" rel="noreferrer" className="remote-link">
|
||||
打开二维码链接
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p className={msg.startsWith('已登录') ? 'remote-qr-hint' : 'remote-error'} role="status">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
527
electron/src/renderer/src/WorkspaceDashboard.tsx
Normal file
527
electron/src/renderer/src/WorkspaceDashboard.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiFetch, apiUrl } from './api'
|
||||
import { STORAGE_MULTI_CHANNEL_PRO } from './constants'
|
||||
import type { WorkspaceDbSnapshot } from './workspace'
|
||||
import { getWorkspace, saveWorkspaceSnapshot } from './workspace'
|
||||
import WorkspaceDeskBackdrop from './WorkspaceDeskBackdrop'
|
||||
import WorkspaceEventSparkline from './WorkspaceEventSparkline'
|
||||
|
||||
function fmtTs(ts: unknown): string {
|
||||
if (typeof ts !== 'number') return '—'
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
type ClientOverview = {
|
||||
youtubeChannelCount?: number
|
||||
youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
|
||||
relayPairingPolicy?: string
|
||||
relayValidationErrors?: string[]
|
||||
relayNotes?: string[]
|
||||
wechat?: {
|
||||
configured?: boolean
|
||||
nickName?: string
|
||||
wcId?: string
|
||||
online?: boolean
|
||||
proxyError?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type EasyTierPeerPayload = {
|
||||
ok?: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
}
|
||||
|
||||
type RelayLivePayload = {
|
||||
youtubeProcess?: string
|
||||
processStatus?: string
|
||||
processOnline?: boolean
|
||||
relayRows?: Array<{
|
||||
channelId?: string | null
|
||||
channelName?: string
|
||||
anchor?: string
|
||||
douyinHint?: string
|
||||
streamTitle?: string
|
||||
youtubeKeySuffix?: string
|
||||
durationSec?: number | null
|
||||
processOnline?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
function fmtDur(sec: number | null | undefined): string {
|
||||
if (sec == null || Number.isNaN(sec)) return '—'
|
||||
const s = Math.floor(sec)
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
if (h > 0) return `${h}h${m}m${r}s`
|
||||
if (m > 0) return `${m}m${r}s`
|
||||
return `${r}s`
|
||||
}
|
||||
|
||||
function SnapshotSummary({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object') return <pre className="ws-pre">—</pre>
|
||||
const d = data as Record<string, unknown>
|
||||
if ('google' in d && 'douyin' in d) {
|
||||
const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
|
||||
const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
|
||||
return (
|
||||
<div className="ws-snap-grid">
|
||||
<div>
|
||||
<span className="ws-snap-k">Google</span>
|
||||
<span className="ws-snap-v">{g?.reachable ? '可达' : '不可达'}</span>
|
||||
<span className="ws-snap-meta">
|
||||
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
|
||||
{g?.country ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="ws-snap-k">抖音</span>
|
||||
<span className="ws-snap-v">{y?.reachable ? '可达' : '不可达'}</span>
|
||||
<span className="ws-snap-meta">
|
||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
|
||||
{y?.country ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if ('cpu_percent' in d || 'memory' in d) {
|
||||
const m = d.memory as { percent?: number } | undefined
|
||||
return (
|
||||
<div className="ws-snap-grid">
|
||||
<div>
|
||||
<span className="ws-snap-k">CPU</span>
|
||||
<span className="ws-snap-v">
|
||||
{typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="ws-snap-k">内存</span>
|
||||
<span className="ws-snap-v">{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre className="ws-pre">{JSON.stringify(data, null, 2).slice(0, 1200)}</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorkspaceDashboard() {
|
||||
const ws = getWorkspace()
|
||||
const [data, setData] = useState<WorkspaceDbSnapshot | null>(null)
|
||||
const [overview, setOverview] = useState<ClientOverview | null>(null)
|
||||
const [peerStats, setPeerStats] = useState<EasyTierPeerPayload | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [proMode, setProMode] = useState(false)
|
||||
const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ws?.getDashboard) {
|
||||
setErr('工作台存储仅在桌面客户端可用。')
|
||||
return
|
||||
}
|
||||
setErr('')
|
||||
try {
|
||||
const d = (await ws.getDashboard()) as WorkspaceDbSnapshot
|
||||
setData(d)
|
||||
const desk = (
|
||||
window as Window & {
|
||||
deskBridge?: { getEasyTierPeers?: () => Promise<EasyTierPeerPayload> }
|
||||
}
|
||||
).deskBridge
|
||||
let ov: ClientOverview | null = null
|
||||
let et: EasyTierPeerPayload | null = null
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/client/overview'))
|
||||
if (r.ok) {
|
||||
ov = (await r.json()) as ClientOverview
|
||||
setOverview(ov)
|
||||
}
|
||||
} catch {
|
||||
setOverview(null)
|
||||
}
|
||||
try {
|
||||
et = (await desk?.getEasyTierPeers?.()) ?? null
|
||||
setPeerStats(et)
|
||||
} catch {
|
||||
setPeerStats(null)
|
||||
}
|
||||
if (ov) {
|
||||
saveWorkspaceSnapshot('client_overview', { ...ov, easyTierPeers: et })
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [ws])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), 12000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setProMode(localStorage.getItem(STORAGE_MULTI_CHANNEL_PRO) === '1')
|
||||
} catch {
|
||||
setProMode(false)
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
try {
|
||||
setProMode(localStorage.getItem(STORAGE_MULTI_CHANNEL_PRO) === '1')
|
||||
} catch {
|
||||
setProMode(false)
|
||||
}
|
||||
}, 3000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const loadProLive = useCallback(async () => {
|
||||
if (!proMode) return
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/relay_pro/live_status'))
|
||||
if (r.ok) {
|
||||
setProLive((await r.json()) as RelayLivePayload)
|
||||
}
|
||||
} catch {
|
||||
setProLive(null)
|
||||
}
|
||||
}, [proMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!proMode) return
|
||||
void loadProLive()
|
||||
const id = window.setInterval(() => void loadProLive(), 8000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [proMode, loadProLive])
|
||||
|
||||
const net = data?.snapshots?.network_status
|
||||
const sys = data?.snapshots?.system_metrics
|
||||
const w = overview?.wechat
|
||||
|
||||
return (
|
||||
<section className="card card--workspace" aria-labelledby="ws-title">
|
||||
<div className="ws-hero">
|
||||
<WorkspaceDeskBackdrop
|
||||
channels={overview?.youtubeChannels ?? []}
|
||||
relayLive={proMode ? proLive : null}
|
||||
recentLiveEventCount={data?.liveEvents?.length ?? 0}
|
||||
peerOnline={peerStats?.onlinePeers ?? 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="ws-head">
|
||||
<div>
|
||||
<h2 className="card__title" id="ws-title">
|
||||
工作台
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
本地 SQLite(用户数据目录 <code className="remote-code">client-data.sqlite</code>)汇总;频道与微信摘要来自本机
|
||||
API;EasyTier 节点数由主进程调用 <code className="remote-code">easytier-cli peer list</code>。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<p className="remote-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{proMode && (
|
||||
<div className="ws-section">
|
||||
<h3 className="ws-section__title">Pro 转播实况</h3>
|
||||
<p className="card__desc ws-pro-live-desc">
|
||||
根据录制进程日志中的 <code className="remote-code">[RELAY_META]</code> 与进程状态汇总;单进程时仅显示当前一路。
|
||||
</p>
|
||||
<div className="ws-card ws-card--live">
|
||||
<div className="ws-card__title">YouTube 录制进程</div>
|
||||
<p className="ws-peer-stats">
|
||||
{proLive?.youtubeProcess ?? '—'} · 状态{' '}
|
||||
<strong>{proLive?.processStatus ?? '—'}</strong>
|
||||
{proLive?.processOnline ? (
|
||||
<span className="ws-tag ws-tag--on">运行中</span>
|
||||
) : (
|
||||
<span className="ws-tag ws-tag--off">未在运行</span>
|
||||
)}
|
||||
</p>
|
||||
{(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
|
||||
<div key={`${row.channelId ?? i}-${row.streamTitle ?? ''}`} className="ws-live-row">
|
||||
<div className="ws-live-row__title">
|
||||
{row.channelName || '未命名频道'}{' '}
|
||||
<span className="ws-live-row__sub">key …{row.youtubeKeySuffix || '—'}</span>
|
||||
</div>
|
||||
<div className="ws-live-row__line">
|
||||
<span className="ws-kv-list__k">主播</span>
|
||||
<span>{row.anchor ?? '—'}</span>
|
||||
</div>
|
||||
<div className="ws-live-row__line">
|
||||
<span className="ws-kv-list__k">抖音</span>
|
||||
<span className="ws-live-row__clip" title={String(row.douyinHint ?? '')}>
|
||||
{row.douyinHint ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ws-live-row__line">
|
||||
<span className="ws-kv-list__k">标题</span>
|
||||
<span className="ws-live-row__clip" title={String(row.streamTitle ?? '')}>
|
||||
{row.streamTitle ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ws-live-row__line">
|
||||
<span className="ws-kv-list__k">已播时长</span>
|
||||
<span>{fmtDur(row.durationSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!proLive?.relayRows?.length && (
|
||||
<p className="ws-card__empty">暂无 RELAY_META(尚未开始推流或日志中无解析记录)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ws-cards ws-cards--wide">
|
||||
<div className="ws-card">
|
||||
<div className="ws-card__title">转播线路(抖音 → YouTube,一对一)</div>
|
||||
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 && (
|
||||
<div className="ws-card__errors" role="alert">
|
||||
{overview.relayValidationErrors.map((e) => (
|
||||
<p key={e}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{overview?.youtubeChannels?.length ? (
|
||||
<>
|
||||
<p className="ws-card__meta">
|
||||
共 {overview.youtubeChannelCount ?? overview.youtubeChannels.length} 条
|
||||
{overview.relayPairingPolicy === 'one_douyin_one_youtube' ? ' · 策略:一抖音一 YouTube' : ''}
|
||||
</p>
|
||||
<ul className="ws-kv-list ws-kv-list--stack">
|
||||
{overview.youtubeChannels.map((c) => (
|
||||
<li key={`${c.name}-${c.keyPreview}-${c.douyinPreview ?? ''}`}>
|
||||
<div className="ws-kv-list__row">
|
||||
<span className="ws-kv-list__k">{c.name}</span>
|
||||
</div>
|
||||
<div className="ws-kv-list__row">
|
||||
<span className="ws-kv-list__sub">抖音</span>
|
||||
<span className="ws-kv-list__v" title={c.douyinPreview}>
|
||||
{c.douyinPreview || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ws-kv-list__row">
|
||||
<span className="ws-kv-list__sub">YouTube key</span>
|
||||
<code className="ws-kv-list__v">{c.keyPreview || '—'}</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="ws-card__hint">
|
||||
桌面端自动维护 <code className="remote-code">youtube_channels.json</code> 与{' '}
|
||||
<code className="remote-code">relay_pro/url_config.*.ini</code>;在「录制」页 Pro 转播中填写抖音与 YouTube 推流密钥即可保存。
|
||||
</p>
|
||||
{overview.relayNotes && overview.relayNotes.length > 0 && (
|
||||
<div className="ws-card__notes" role="note">
|
||||
{overview.relayNotes.map((n) => (
|
||||
<p key={n}>{n}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="ws-card__empty">
|
||||
{overview?.relayValidationErrors?.length
|
||||
? '线路配置存在冲突(如同一抖音重复绑定),请在「录制」页调整后保存。'
|
||||
: '未读取到转播摘要(请确认本机服务已启动;或稍后在「录制」页 Pro 转播中填写)。'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ws-card">
|
||||
<div className="ws-card__title">EasyTier 组网节点</div>
|
||||
{peerStats?.ok ? (
|
||||
<>
|
||||
<p className="ws-card__meta">CLI 解析的可见节点(含手机 / RN 等)</p>
|
||||
<p className="ws-peer-stats">
|
||||
约 <strong>{peerStats.totalPeers ?? 0}</strong> 个节点,在线约{' '}
|
||||
<strong>{peerStats.onlinePeers ?? 0}</strong> 个
|
||||
</p>
|
||||
<p className="ws-card__hint">需 easytier-core 已运行;解析依赖 CLI 表格格式,仅供参考。</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="ws-card__empty">{peerStats?.error || '仅在 Electron 桌面端可查询;或核心未运行。'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ws-card">
|
||||
<div className="ws-card__title">微信 Chatbot</div>
|
||||
{w ? (
|
||||
<>
|
||||
<p className="ws-peer-stats">
|
||||
{w.configured ? (
|
||||
<>
|
||||
已配置代理{' '}
|
||||
<span className={w.online ? 'ws-tag ws-tag--on' : 'ws-tag ws-tag--off'}>
|
||||
{w.online ? '会话在线' : '未确认在线'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'未配置 apiKey / proxyUrl'
|
||||
)}
|
||||
</p>
|
||||
{(w.nickName || w.wcId) && (
|
||||
<ul className="ws-kv-list">
|
||||
{w.nickName && (
|
||||
<li>
|
||||
<span className="ws-kv-list__k">昵称</span>
|
||||
<span className="ws-kv-list__v">{w.nickName}</span>
|
||||
</li>
|
||||
)}
|
||||
{w.wcId && (
|
||||
<li>
|
||||
<span className="ws-kv-list__k">wcId</span>
|
||||
<code className="ws-kv-list__v ws-mono">{String(w.wcId)}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{w.proxyError && (
|
||||
<p className="remote-warn" role="status">
|
||||
代理状态:{w.proxyError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="ws-card__empty">未拉取(请确认本机服务已启动)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ws-card">
|
||||
<div className="ws-card__title">网络快照</div>
|
||||
{net ? (
|
||||
<>
|
||||
<p className="ws-card__meta">更新 {fmtTs(net.ts)}</p>
|
||||
<SnapshotSummary data={net.data} />
|
||||
</>
|
||||
) : (
|
||||
<p className="ws-card__empty">暂无(打开「网络」页后会自动写入)</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ws-card">
|
||||
<div className="ws-card__title">本机负载快照</div>
|
||||
{sys ? (
|
||||
<>
|
||||
<p className="ws-card__meta">更新 {fmtTs(sys.ts)}</p>
|
||||
<SnapshotSummary data={sys.data} />
|
||||
</>
|
||||
) : (
|
||||
<p className="ws-card__empty">暂无(打开「系统」页后会自动写入)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ws-section">
|
||||
<h3 className="ws-section__title">直播与任务事件(含转播意图 / 时长)</h3>
|
||||
<p className="card__desc" style={{ marginTop: 0, marginBottom: '0.6rem' }}>
|
||||
下方曲线为约 72 分钟内、按 2 分钟桶汇总的本地事件条数(ECharts)。
|
||||
</p>
|
||||
<WorkspaceEventSparkline rows={data?.liveEvents} />
|
||||
<div className="ws-table-wrap">
|
||||
<table className="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>进程</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>说明</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.liveEvents?.length ? data.liveEvents : []).map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{fmtTs(row.ts)}</td>
|
||||
<td className="ws-mono">{String(row.process ?? '—')}</td>
|
||||
<td>{String(row.event_type ?? '—')}</td>
|
||||
<td>{String(row.status ?? '—')}</td>
|
||||
<td className="ws-cell-clip" title={String(row.message ?? '')}>
|
||||
{String(row.message ?? '—')}
|
||||
</td>
|
||||
<td className="ws-cell-clip ws-mono" title={String(row.detail_json ?? '')}>
|
||||
{row.detail_json ? String(row.detail_json).slice(0, 160) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!data?.liveEvents?.length && <p className="ws-empty">暂无记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ws-section">
|
||||
<h3 className="ws-section__title">用户操作</h3>
|
||||
<div className="ws-table-wrap">
|
||||
<table className="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
<th>页面</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.userActions?.length ? data.userActions : []).map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{fmtTs(row.ts)}</td>
|
||||
<td>{String(row.action ?? '—')}</td>
|
||||
<td>{String(row.nav ?? '—')}</td>
|
||||
<td className="ws-cell-clip ws-mono" title={String(row.detail_json ?? '')}>
|
||||
{row.detail_json ? String(row.detail_json).slice(0, 120) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!data?.userActions?.length && <p className="ws-empty">暂无记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ws-section">
|
||||
<h3 className="ws-section__title">客户端运行日志</h3>
|
||||
<div className="ws-table-wrap">
|
||||
<table className="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>级别</th>
|
||||
<th>来源</th>
|
||||
<th>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.clientLogs?.length ? data.clientLogs : []).map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{fmtTs(row.ts)}</td>
|
||||
<td>{String(row.level ?? '—')}</td>
|
||||
<td>{String(row.source ?? '—')}</td>
|
||||
<td className="ws-cell-clip">{String(row.message ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!data?.clientLogs?.length && <p className="ws-empty">暂无记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
312
electron/src/renderer/src/WorkspaceDeskBackdrop.tsx
Normal file
312
electron/src/renderer/src/WorkspaceDeskBackdrop.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/** 与 /client/overview 的 youtubeChannels 项一致 */
|
||||
export type DeskChannel = {
|
||||
name: string
|
||||
douyinPreview?: string
|
||||
keyPreview?: string
|
||||
}
|
||||
|
||||
export type DeskRelayLive = {
|
||||
processOnline?: boolean
|
||||
relayRows?: Array<{
|
||||
channelId?: string | null
|
||||
channelName?: string
|
||||
durationSec?: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
channels: DeskChannel[]
|
||||
relayLive: DeskRelayLive | null
|
||||
/** 本地 SQLite 近期事件条数,用于无线路配置时的活动强度参考 */
|
||||
recentLiveEventCount: number
|
||||
peerOnline: number
|
||||
}
|
||||
|
||||
function cssAccentHex(): string {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
|
||||
return v || '#5b9dff'
|
||||
}
|
||||
|
||||
/** 业务:配置完整度 0 / 0.5 / 1(与后台 overview 展示逻辑一致) */
|
||||
function channelReadiness(c: DeskChannel): number {
|
||||
const d = (c.douyinPreview || '').includes('待填写')
|
||||
const k = (c.keyPreview || '').includes('待填写')
|
||||
if (!d && !k) return 1
|
||||
if (d && k) return 0
|
||||
return 0.5
|
||||
}
|
||||
|
||||
function durationForChannel(name: string, relay: DeskRelayLive | null): number {
|
||||
const rows = relay?.relayRows ?? []
|
||||
const n = name.trim()
|
||||
const hit = rows.find((r) => (r.channelName || '').trim() === n)
|
||||
const s = hit?.durationSec
|
||||
return typeof s === 'number' && !Number.isNaN(s) ? s : 0
|
||||
}
|
||||
|
||||
function buildThreeDataset(
|
||||
channels: DeskChannel[],
|
||||
relay: DeskRelayLive | null,
|
||||
recentLiveEventCount: number,
|
||||
): { positions: Float32Array; colors: Float32Array; count: number } {
|
||||
const list = channels.length ? channels.slice(0, 10) : [{ name: '(暂无线路)', douyinPreview: '(待填写)', keyPreview: '(待填写)' }]
|
||||
const rows = list.length
|
||||
let total = 0
|
||||
const capPerRow: number[] = []
|
||||
for (let i = 0; i < rows; i++) {
|
||||
const c = list[i]!
|
||||
const r = channelReadiness(c)
|
||||
const dur = channels.length ? durationForChannel(c.name, relay) : 0
|
||||
const n = Math.min(
|
||||
200,
|
||||
Math.max(
|
||||
14,
|
||||
Math.floor(
|
||||
16 +
|
||||
dur / 22 +
|
||||
r * 36 +
|
||||
(channels.length ? 0 : Math.min(120, recentLiveEventCount / 2)),
|
||||
),
|
||||
),
|
||||
)
|
||||
capPerRow.push(n)
|
||||
total += n
|
||||
}
|
||||
|
||||
const positions = new Float32Array(total * 3)
|
||||
const colors = new Float32Array(total * 3)
|
||||
let offset = 0
|
||||
const ySpan = Math.max(1, rows - 1)
|
||||
for (let i = 0; i < rows; i++) {
|
||||
const c = list[i]!
|
||||
const r = channelReadiness(c)
|
||||
const y = rows === 1 ? 0 : (i / ySpan) * 1.6 - 0.8
|
||||
const n = capPerRow[i]!
|
||||
const hue = 0.28 + r * 0.22
|
||||
const sat = 0.35 + r * 0.45
|
||||
const light = 0.42 + r * 0.22
|
||||
const base = new THREE.Color().setHSL(hue, sat, light)
|
||||
for (let j = 0; j < n; j++) {
|
||||
const t = j / Math.max(1, n - 1)
|
||||
const x = (t - 0.5) * 5.2 + (Math.random() - 0.5) * 0.12
|
||||
const z = (Math.random() - 0.5) * 0.35
|
||||
positions[(offset + j) * 3] = x
|
||||
positions[(offset + j) * 3 + 1] = y + (Math.random() - 0.5) * 0.08
|
||||
positions[(offset + j) * 3 + 2] = z
|
||||
const jitter = 0.85 + Math.random() * 0.15
|
||||
colors[(offset + j) * 3] = base.r * jitter
|
||||
colors[(offset + j) * 3 + 1] = base.g * jitter
|
||||
colors[(offset + j) * 3 + 2] = base.b * jitter
|
||||
}
|
||||
offset += n
|
||||
}
|
||||
return { positions, colors, count: total }
|
||||
}
|
||||
|
||||
export default function WorkspaceDeskBackdrop({
|
||||
channels,
|
||||
relayLive,
|
||||
recentLiveEventCount,
|
||||
peerOnline,
|
||||
}: Props) {
|
||||
const threeMountRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
const dataKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
ch: channels.map((c) => [c.name, c.douyinPreview, c.keyPreview]),
|
||||
relay: relayLive?.relayRows?.map((r) => [r.channelName, r.channelId, r.durationSec]),
|
||||
ev: recentLiveEventCount,
|
||||
peer: peerOnline,
|
||||
}),
|
||||
[channels, relayLive, recentLiveEventCount, peerOnline],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mount = threeMountRef.current
|
||||
if (!mount) return
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
let w = mount.clientWidth || 600
|
||||
let h = mount.clientHeight || 176
|
||||
if (w < 40) w = 600
|
||||
|
||||
const { positions, colors, count } = buildThreeDataset(channels, relayLive, recentLiveEventCount)
|
||||
if (count === 0) return
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(48, w / h, 0.1, 100)
|
||||
camera.position.z = 4.8
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, premultipliedAlpha: false })
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setSize(w, h)
|
||||
mount.appendChild(renderer.domElement)
|
||||
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||||
const mat = new THREE.PointsMaterial({
|
||||
size: 0.032,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.88,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
})
|
||||
const points = new THREE.Points(geo, mat)
|
||||
scene.add(points)
|
||||
|
||||
let raf = 0
|
||||
const t0 = performance.now()
|
||||
const tick = () => {
|
||||
const t = (performance.now() - t0) * 0.0001
|
||||
if (!reduced) {
|
||||
points.rotation.y = t * 0.65
|
||||
points.rotation.x = Math.sin(t * 0.3) * 0.08
|
||||
}
|
||||
renderer.render(scene, camera)
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!threeMountRef.current) return
|
||||
const ww = threeMountRef.current.clientWidth || 600
|
||||
const hh = threeMountRef.current.clientHeight || 176
|
||||
camera.aspect = ww / hh
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(ww, hh)
|
||||
})
|
||||
ro.observe(mount)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
geo.dispose()
|
||||
mat.dispose()
|
||||
renderer.dispose()
|
||||
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement)
|
||||
}
|
||||
}, [dataKey])
|
||||
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return
|
||||
|
||||
const w = Math.max(svg.clientWidth || 0, 320)
|
||||
const h = Math.max(svg.clientHeight || 0, 168)
|
||||
const margin = { top: 14, right: 10, bottom: 36, left: 8 }
|
||||
const innerW = w - margin.left - margin.right
|
||||
const innerH = h - margin.top - margin.bottom
|
||||
|
||||
const list = channels.length
|
||||
? channels.slice(0, 10)
|
||||
: [{ name: '暂无线路', douyinPreview: '(待填写)', keyPreview: '(待填写)' }]
|
||||
const rows = list.map((c) => ({
|
||||
name: c.name.length > 8 ? `${c.name.slice(0, 7)}…` : c.name,
|
||||
fullName: c.name,
|
||||
r: channelReadiness(c),
|
||||
}))
|
||||
|
||||
const x = d3
|
||||
.scaleBand()
|
||||
.domain(rows.map((d) => d.name))
|
||||
.range([0, innerW])
|
||||
.padding(0.22)
|
||||
|
||||
const y = d3.scaleLinear().domain([0, 1]).range([innerH, 0])
|
||||
|
||||
const root = d3.select(svg)
|
||||
root.selectAll('*').remove()
|
||||
const g = root.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
|
||||
|
||||
const accent = cssAccentHex()
|
||||
|
||||
g.append('text')
|
||||
.attr('x', 0)
|
||||
.attr('y', -2)
|
||||
.attr('fill', accent)
|
||||
.attr('font-size', 11)
|
||||
.attr('font-weight', 600)
|
||||
.text('线路配置完整度')
|
||||
|
||||
const barG = g
|
||||
.selectAll('g.barcell')
|
||||
.data(rows)
|
||||
.join('g')
|
||||
.attr('class', 'barcell')
|
||||
|
||||
barG
|
||||
.append('rect')
|
||||
.attr('class', 'bar')
|
||||
.attr('x', (d) => x(d.name) ?? 0)
|
||||
.attr('y', (d) => y(d.r))
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('height', (d) => innerH - y(d.r))
|
||||
.attr('rx', 4)
|
||||
.attr('fill', (d) =>
|
||||
d.r >= 1 ? 'rgba(61, 214, 140, 0.55)' : d.r >= 0.5 ? 'rgba(246, 173, 85, 0.5)' : 'rgba(139, 146, 158, 0.45)',
|
||||
)
|
||||
.attr('stroke', accent)
|
||||
.attr('stroke-opacity', 0.35)
|
||||
|
||||
barG.append('title').text((d) => `${d.fullName} · 完整度 ${(d.r * 100).toFixed(0)}%`)
|
||||
|
||||
g.selectAll('text.pct')
|
||||
.data(rows)
|
||||
.join('text')
|
||||
.attr('class', 'pct')
|
||||
.attr('x', (d) => (x(d.name) ?? 0) + x.bandwidth() / 2)
|
||||
.attr('y', (d) => y(d.r) - 4)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#e8eaed')
|
||||
.attr('font-size', 10)
|
||||
.text((d) => `${(d.r * 100).toFixed(0)}%`)
|
||||
|
||||
g.selectAll('text.xlab')
|
||||
.data(rows)
|
||||
.join('text')
|
||||
.attr('class', 'xlab')
|
||||
.attr('x', (d) => (x(d.name) ?? 0) + x.bandwidth() / 2)
|
||||
.attr('y', innerH + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() || '#8b929e')
|
||||
.attr('font-size', 9)
|
||||
.attr('transform', (d) => {
|
||||
const cx = (x(d.name) ?? 0) + x.bandwidth() / 2
|
||||
const cy = innerH + 14
|
||||
return `rotate(-28 ${cx} ${cy})`
|
||||
})
|
||||
.text((d) => d.name)
|
||||
|
||||
return () => {
|
||||
root.selectAll('*').remove()
|
||||
}
|
||||
}, [dataKey])
|
||||
|
||||
const peerHint =
|
||||
peerOnline > 0
|
||||
? `EasyTier 约 ${peerOnline} 节点在线 · 点云密度 ∝ 推流时长与配置完整度`
|
||||
: '点云密度 ∝ 推流时长与配置完整度(无 EasyTier 数据时仅显示配置与事件强度)'
|
||||
|
||||
return (
|
||||
<div className="ws-desk-backdrop">
|
||||
<div className="ws-desk-backdrop__three" ref={threeMountRef} aria-hidden />
|
||||
<svg className="ws-desk-backdrop__d3" ref={svgRef} width="100%" height="100%" aria-hidden />
|
||||
<div className="ws-desk-backdrop__fade" aria-hidden />
|
||||
<div className="ws-desk-backdrop__legend" role="region" aria-label="转播业务可视化说明">
|
||||
<strong>业务视图</strong>
|
||||
<span>D3:各转播线路抖音+YouTube 配置完整度</span>
|
||||
<span>Three:同线路点云密度反映 RELAY 推流时长与完整度(无线路时用近期事件量作参考)</span>
|
||||
<span className="ws-desk-backdrop__legend-sub">{peerHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
electron/src/renderer/src/WorkspaceEventSparkline.tsx
Normal file
131
electron/src/renderer/src/WorkspaceEventSparkline.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
type LiveRow = { ts?: unknown }
|
||||
|
||||
function bucketCounts(rows: LiveRow[], bucketMs: number, spanMs: number): { labels: string[]; values: number[] } {
|
||||
const now = Date.now()
|
||||
const start = now - spanMs
|
||||
const n = Math.max(1, Math.ceil(spanMs / bucketMs))
|
||||
const buckets = new Array<number>(n).fill(0)
|
||||
for (const r of rows) {
|
||||
const t = typeof r.ts === 'number' ? r.ts : 0
|
||||
if (t < start || t > now) continue
|
||||
const idx = Math.min(n - 1, Math.floor((t - start) / bucketMs))
|
||||
buckets[idx] += 1
|
||||
}
|
||||
const labels = buckets.map((_, i) => {
|
||||
const t = new Date(start + i * bucketMs)
|
||||
return t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
return { labels, values: buckets }
|
||||
}
|
||||
|
||||
export default function WorkspaceEventSparkline({ rows }: { rows: LiveRow[] | undefined }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<echarts.ECharts | null>(null)
|
||||
|
||||
const series = useMemo(() => {
|
||||
const list = rows?.length ? [...rows] : []
|
||||
return bucketCounts(list, 120_000, 72 * 60_000)
|
||||
}, [rows])
|
||||
|
||||
const applyChart = useMemo(() => {
|
||||
return () => {
|
||||
const chart = chartRef.current
|
||||
if (!chart) return
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
const accent = root.getPropertyValue('--accent').trim() || '#5b9dff'
|
||||
const muted = root.getPropertyValue('--text-muted').trim() || '#8b929e'
|
||||
const border = root.getPropertyValue('--border-subtle').trim() || 'rgba(255,255,255,0.06)'
|
||||
const text = root.getPropertyValue('--text').trim() || '#e8eaed'
|
||||
const tipBg =
|
||||
document.documentElement.getAttribute('data-theme') === 'light'
|
||||
? 'rgba(255,255,255,0.96)'
|
||||
: 'rgba(22, 24, 30, 0.92)'
|
||||
|
||||
chart.setOption({
|
||||
animationDuration: 520,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: { left: 44, right: 12, top: 18, bottom: 28 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: tipBg,
|
||||
borderColor: border,
|
||||
textStyle: { color: text, fontSize: 12 },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: series.labels,
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted, fontSize: 10, rotate: 32 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: { lineStyle: { color: border, type: 'dashed' } },
|
||||
axisLabel: { color: muted, fontSize: 10 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: series.values,
|
||||
smooth: true,
|
||||
showSymbol: series.values.length <= 24,
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 2, color: accent },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${accent}55` },
|
||||
{ offset: 1, color: `${accent}08` },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}, [series])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const chart = echarts.init(el, undefined, { renderer: 'canvas' })
|
||||
chartRef.current = chart
|
||||
const ro = new ResizeObserver(() => chart.resize())
|
||||
ro.observe(el)
|
||||
requestAnimationFrame(() => applyChart())
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
chart.dispose()
|
||||
chartRef.current = null
|
||||
}
|
||||
}, [applyChart])
|
||||
|
||||
useEffect(() => {
|
||||
applyChart()
|
||||
}, [applyChart])
|
||||
|
||||
useEffect(() => {
|
||||
const mo = new MutationObserver(() => applyChart())
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => mo.disconnect()
|
||||
}, [applyChart])
|
||||
|
||||
return (
|
||||
<div className="ws-chart-wrap">
|
||||
<div ref={ref} className="ws-chart" role="img" aria-label="最近约 72 分钟内直播事件条数" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
electron/src/renderer/src/api.ts
Normal file
44
electron/src/renderer/src/api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const base = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001'
|
||||
|
||||
/** 由 Electron 主进程从 config/web2_api_token.txt 注入;无则走 VITE / localStorage */
|
||||
let bearerFromMain: string | null = null
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base.replace(/\/$/, '')}${p}`
|
||||
}
|
||||
|
||||
function getBearerToken(): string | null {
|
||||
if (bearerFromMain) return bearerFromMain
|
||||
const v = import.meta.env.VITE_WEB2_API_TOKEN
|
||||
if (v && String(v).trim()) return String(v).trim()
|
||||
try {
|
||||
return localStorage.getItem('web2_api_token')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function initApiFromMain(): Promise<void> {
|
||||
bearerFromMain = null
|
||||
try {
|
||||
const w = (window as Window & { web2Remote?: { getApiToken?: () => Promise<string | null> } }).web2Remote
|
||||
const t = await w?.getApiToken?.()
|
||||
if (t && String(t).trim()) bearerFromMain = String(t).trim()
|
||||
} catch {
|
||||
/* 非 Electron 或 preload 未注入 */
|
||||
}
|
||||
if (!bearerFromMain) {
|
||||
const v = import.meta.env.VITE_WEB2_API_TOKEN
|
||||
if (v && String(v).trim()) bearerFromMain = String(v).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
const token = getBearerToken()
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return fetch(input, { ...init, headers })
|
||||
}
|
||||
8
electron/src/renderer/src/constants.ts
Normal file
8
electron/src/renderer/src/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 为 true 时:下拉只展示 youtube*.py */
|
||||
export const UI_DROPDOWN_YOUTUBE_ONLY = true
|
||||
|
||||
/** 为 true 时:YouTube 卡片显示「Google 授权 / 拉取串流密钥」;false 仅保留手动编辑配置(后端路由仍保留) */
|
||||
export const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false
|
||||
|
||||
/** localStorage:多路转播 Pro 偏好(多组「抖音↔YouTube」一对一并行;禁止一对多/共用密钥) */
|
||||
export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro'
|
||||
13
electron/src/renderer/src/main.tsx
Normal file
13
electron/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { initApiFromMain } from './api'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
void initApiFromMain().then(() => {
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
})
|
||||
17
electron/src/renderer/src/scriptUtils.ts
Normal file
17
electron/src/renderer/src/scriptUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function pm2NameFromScript(script: string): string {
|
||||
const base = String(script).split(/[/\\]/).pop() || ''
|
||||
const i = base.lastIndexOf('.')
|
||||
return i > 0 ? base.slice(0, i) : base
|
||||
}
|
||||
|
||||
export function isYoutubeScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
|
||||
}
|
||||
|
||||
export function isTiktokScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
|
||||
}
|
||||
|
||||
export function isObsScript(script: string): boolean {
|
||||
return pm2NameFromScript(script).startsWith('obs')
|
||||
}
|
||||
84
electron/src/renderer/src/vite-env.d.ts
vendored
Normal file
84
electron/src/renderer/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_WEB2_API_TOKEN?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
recorderConsole?: {
|
||||
platform: string
|
||||
openExternal?: (url: string) => Promise<void>
|
||||
}
|
||||
web2Remote?: {
|
||||
getApiToken?: () => Promise<string | null>
|
||||
}
|
||||
remoteBinding?: {
|
||||
getStatus: () => Promise<{
|
||||
bindHost: string
|
||||
remoteEnabled: boolean
|
||||
port: number
|
||||
hasToken: boolean
|
||||
candidates: { address: string; name: string }[]
|
||||
envHostOverride: boolean
|
||||
easytierBundled: boolean
|
||||
easytierRunning: boolean
|
||||
easytierLastError: string
|
||||
easytierNetworkName: string
|
||||
}>
|
||||
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
workspace?: {
|
||||
logLiveEvent: (payload: unknown) => Promise<void>
|
||||
logUserAction: (payload: unknown) => Promise<void>
|
||||
logClient: (payload: unknown) => Promise<void>
|
||||
saveSnapshot: (key: string, data: unknown) => Promise<void>
|
||||
getDashboard: () => Promise<{
|
||||
liveEvents: Array<Record<string, unknown>>
|
||||
userActions: Array<Record<string, unknown>>
|
||||
clientLogs: Array<Record<string, unknown>>
|
||||
snapshots: Record<string, { ts: number; data: unknown }>
|
||||
}>
|
||||
}
|
||||
deskBridge?: {
|
||||
getEasyTierPeers?: () => Promise<{
|
||||
ok?: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
}>
|
||||
ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void>
|
||||
}
|
||||
appTheme?: {
|
||||
get?: () => Promise<string | null>
|
||||
set?: (value: string) => Promise<void>
|
||||
}
|
||||
relayConfig?: {
|
||||
syncConfig?: () => Promise<{ ok: true }>
|
||||
importFromConfig?: () => Promise<{ ok: true }>
|
||||
}
|
||||
appHost?: {
|
||||
onNavigate?: (cb: (nav: string) => void) => () => void
|
||||
getLoginItemSettings?: () => Promise<Record<string, unknown>>
|
||||
setLoginItemOpenAtLogin?: (v: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
relaunch?: () => Promise<void>
|
||||
getMetrics?: () => Promise<unknown[]>
|
||||
showAbout?: () => Promise<void>
|
||||
setBadge?: (n: number) => Promise<void>
|
||||
getPaths?: () => Promise<{
|
||||
userData: string
|
||||
logs: string
|
||||
temp: string
|
||||
backendRoot: string
|
||||
backendConfig: string
|
||||
}>
|
||||
addRecentConfig?: () => Promise<void>
|
||||
openLogsFolder?: () => Promise<void>
|
||||
}
|
||||
}
|
||||
40
electron/src/renderer/src/workspace.ts
Normal file
40
electron/src/renderer/src/workspace.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** 主进程 SQLite 工作台 API(仅 Electron preload 注入时可用) */
|
||||
|
||||
export type WorkspaceDbSnapshot = {
|
||||
liveEvents: Array<Record<string, unknown>>
|
||||
userActions: Array<Record<string, unknown>>
|
||||
clientLogs: Array<Record<string, unknown>>
|
||||
snapshots: Record<string, { ts: number; data: unknown }>
|
||||
}
|
||||
|
||||
export function getWorkspace() {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as Window & { workspace?: WorkspaceApi }).workspace
|
||||
: undefined
|
||||
}
|
||||
|
||||
type WorkspaceApi = {
|
||||
logLiveEvent: (payload: unknown) => Promise<void>
|
||||
logUserAction: (payload: unknown) => Promise<void>
|
||||
logClient: (payload: unknown) => Promise<void>
|
||||
saveSnapshot: (key: string, data: unknown) => Promise<void>
|
||||
getDashboard: () => Promise<WorkspaceDbSnapshot>
|
||||
}
|
||||
|
||||
export function saveWorkspaceSnapshot(key: string, data: unknown): void {
|
||||
void getWorkspace()?.saveSnapshot(key, data)
|
||||
}
|
||||
|
||||
export function logUserAction(action: string, nav?: string, detail?: unknown): void {
|
||||
void getWorkspace()?.logUserAction({ action, nav, detail })
|
||||
}
|
||||
|
||||
export function logLiveEvent(payload: {
|
||||
eventType: string
|
||||
process?: string
|
||||
status?: string
|
||||
message?: string
|
||||
detail?: unknown
|
||||
}): void {
|
||||
void getWorkspace()?.logLiveEvent(payload)
|
||||
}
|
||||
36
electron/src/renderer/src/youtubeConfigUtils.ts
Normal file
36
electron/src/renderer/src/youtubeConfigUtils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** 从 youtube.ini 文本解析串流密钥(用于匹配 Studio 页与侧窗提示) */
|
||||
export function parseYoutubeStreamKeySuffix(iniContent: string): string {
|
||||
for (const line of iniContent.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t.startsWith('#') || !t) continue
|
||||
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i)
|
||||
if (m) {
|
||||
const v = m[1].trim().split(/[#;]/)[0].trim()
|
||||
if (v && !v.startsWith('rtmp')) return v.length > 8 ? v.slice(-8) : v
|
||||
if (v.includes('live2/')) {
|
||||
const parts = v.split('/')
|
||||
const last = parts[parts.length - 1] || ''
|
||||
return last.length > 8 ? last.slice(-8) : last
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** URL_config.ini 文本中提取抖音直播间线索(房间号或短链片段) */
|
||||
export function extractDouyinRoomHints(urlIni: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const line of urlIni.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
|
||||
const m = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
|
||||
if (m) {
|
||||
out.push(m[1])
|
||||
continue
|
||||
}
|
||||
const n = t.match(/(\d{8,})/)
|
||||
out.push(n ? n[1] : t.slice(0, 96))
|
||||
}
|
||||
return [...new Set(out)].slice(0, 12)
|
||||
}
|
||||
Reference in New Issue
Block a user