mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-15 20:50:43 +08:00
P1 已经把 backend-warning / backend-terminated 横幅做出来了;P2 把
lib.rs 那条 stdout/stderr/terminated 信息流真正落到一个常驻 UI 上:
- 右下角浮动状态点(绿/黄/红),轮询 /api/sys_health 决定颜色
- 点开抽屉看最近 200 行日志(ring buffer),含「重启后端」「复制日志」按钮
Rust:
- src-tauri/src/lib.rs:把 sidecar 启动抽出 spawn_backend_sidecar(),
CommandChild 存进 SidecarHandle(Mutex<Option<CommandChild>>) 这个 state
- 新增 #[tauri::command] restart_backend_sidecar:kill 旧 child + 重新 spawn +
emit 'backend-restarted' 给前端
- 监听任务 stdout/stderr emit 时不再用 format!("'{}'", ...) 包引号,原文直传;
前端 hook 同时兼容旧形式(兜底剥引号)
前端:
- components/BackendHealth/useBackendEvents.ts:listen 四个事件 +
ring buffer (MAX 200 行) + invoke restart + clipboard 复制日志
- BackendHealthIndicator.tsx:右下角浮动状态点,5s 轮询 /api/sys_health;
连续 3 次失败或 backend-terminated 触发 → 红
- BackendLogPanel.tsx:右侧抽屉,深色 monospace 日志区 + 操作按钮
- 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载
P3 / P4 还在路上。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
3.5 KiB
TypeScript
120 lines
3.5 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
|
||
// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error /
|
||
// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer,
|
||
// 同时维护进程运行状态。
|
||
|
||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||
|
||
export type LogLevel = 'info' | 'error'
|
||
|
||
export interface LogEntry {
|
||
level: LogLevel
|
||
text: string
|
||
ts: number
|
||
}
|
||
|
||
export type BackendStatus = 'running' | 'terminated'
|
||
|
||
const MAX_LOG_LINES = 200
|
||
|
||
interface BackendEvents {
|
||
status: BackendStatus
|
||
exitCode: number | null
|
||
logs: LogEntry[]
|
||
/** 调 Tauri 命令重启 sidecar */
|
||
restart: () => Promise<void>
|
||
/** 复制全部日志到剪贴板 */
|
||
copyLogs: () => Promise<boolean>
|
||
isTauri: boolean
|
||
}
|
||
|
||
export function useBackendEvents(): BackendEvents {
|
||
const [status, setStatus] = useState<BackendStatus>('running')
|
||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||
// 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值
|
||
const logsRef = useRef<LogEntry[]>([])
|
||
|
||
function append(entry: LogEntry) {
|
||
const next = logsRef.current.concat(entry)
|
||
if (next.length > MAX_LOG_LINES)
|
||
next.splice(0, next.length - MAX_LOG_LINES)
|
||
logsRef.current = next
|
||
setLogs(next)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!isTauri) return
|
||
|
||
let unlisteners: Array<() => void> = []
|
||
|
||
;(async () => {
|
||
const { listen } = await import('@tauri-apps/api/event')
|
||
|
||
const offMsg = await listen<string>('backend-message', event => {
|
||
append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() })
|
||
})
|
||
const offErr = await listen<string>('backend-error', event => {
|
||
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
|
||
})
|
||
const offTerm = await listen<number | null>('backend-terminated', event => {
|
||
setStatus('terminated')
|
||
setExitCode(event.payload ?? null)
|
||
append({
|
||
level: 'error',
|
||
text: `[Backend terminated] code=${event.payload ?? 'unknown'}`,
|
||
ts: Date.now(),
|
||
})
|
||
})
|
||
const offRestart = await listen('backend-restarted', () => {
|
||
setStatus('running')
|
||
setExitCode(null)
|
||
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
|
||
})
|
||
|
||
unlisteners = [offMsg, offErr, offTerm, offRestart]
|
||
})()
|
||
|
||
return () => {
|
||
unlisteners.forEach(fn => fn())
|
||
}
|
||
}, [])
|
||
|
||
async function restart() {
|
||
if (!isTauri) return
|
||
const { invoke } = await import('@tauri-apps/api/core')
|
||
try {
|
||
await invoke('restart_backend_sidecar')
|
||
}
|
||
catch (e) {
|
||
append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() })
|
||
throw e
|
||
}
|
||
}
|
||
|
||
async function copyLogs() {
|
||
const text = logsRef.current
|
||
.map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`)
|
||
.join('\n')
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
return true
|
||
}
|
||
catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return { status, exitCode, logs, restart, copyLogs, isTauri }
|
||
}
|
||
|
||
// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。
|
||
// 这里做兼容:去掉外层单引号(如果有的话)。
|
||
function stripQuotes(s: string): string {
|
||
if (typeof s !== 'string') return String(s)
|
||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
|
||
return s.slice(1, -1)
|
||
return s
|
||
}
|