mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-12 02:20:18 +08:00
Compare commits
7 Commits
v2.1.4
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1329390f98 | ||
|
|
9a64a2da8e | ||
|
|
e89090bed0 | ||
|
|
edf2083d71 | ||
|
|
a7c717abbd | ||
|
|
799ab64a28 | ||
|
|
c0837e0132 |
@@ -70,6 +70,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
||||
// B 站:先在浏览器里抓字幕(带本地登录态 cookie),随提交带过去
|
||||
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
|
||||
|
||||
const formats = settings.formats || []
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/generate_note`, {
|
||||
method: 'POST',
|
||||
@@ -80,13 +81,15 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
||||
quality: settings.quality,
|
||||
provider_id: settings.providerId,
|
||||
model_name: settings.modelName,
|
||||
screenshot: settings.screenshot,
|
||||
link: settings.link,
|
||||
// backend 同时接受 format 数组与 screenshot/link 单独布尔;从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.style || undefined,
|
||||
format: [
|
||||
...(settings.screenshot ? ['screenshot'] : []),
|
||||
...(settings.link ? ['link'] : []),
|
||||
],
|
||||
extras: settings.extras || undefined,
|
||||
video_understanding: settings.video_understanding || undefined,
|
||||
video_interval: settings.video_understanding ? settings.video_interval : undefined,
|
||||
grid_size: settings.video_understanding ? settings.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -7,9 +7,14 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
providerId: '',
|
||||
modelName: '',
|
||||
quality: 'medium',
|
||||
formats: ['toc', 'summary'],
|
||||
screenshot: false,
|
||||
link: false,
|
||||
style: '',
|
||||
style: 'minimal',
|
||||
extras: '',
|
||||
video_understanding: false,
|
||||
video_interval: 6,
|
||||
grid_size: [2, 2],
|
||||
}
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface GenerateRequest {
|
||||
format?: string[]
|
||||
style?: string
|
||||
extras?: string
|
||||
video_understanding?: boolean
|
||||
video_interval?: number
|
||||
grid_size?: [number, number]
|
||||
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
|
||||
prefetched_transcript?: {
|
||||
language: string
|
||||
@@ -78,14 +81,52 @@ export interface TaskRecord {
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
|
||||
export type NoteStyle =
|
||||
| 'minimal' | 'detailed' | 'academic' | 'tutorial'
|
||||
| 'xiaohongshu' | 'life_journal' | 'task_oriented'
|
||||
| 'business' | 'meeting_minutes'
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_formats 一一对齐
|
||||
export type NoteFormat = 'toc' | 'link' | 'screenshot' | 'summary'
|
||||
|
||||
export const NOTE_STYLES: Array<{ value: NoteStyle, label: string }> = [
|
||||
{ value: 'minimal', label: '精简' },
|
||||
{ value: 'detailed', label: '详细' },
|
||||
{ value: 'tutorial', label: '教程' },
|
||||
{ value: 'academic', label: '学术' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'life_journal', label: '生活向' },
|
||||
{ value: 'task_oriented', label: '任务导向' },
|
||||
{ value: 'business', label: '商业风格' },
|
||||
{ value: 'meeting_minutes', label: '会议纪要' },
|
||||
]
|
||||
|
||||
export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [
|
||||
{ value: 'toc', label: '目录' },
|
||||
{ value: 'summary', label: 'AI 总结' },
|
||||
{ value: 'screenshot', label: '原片截图' },
|
||||
{ value: 'link', label: '原片跳转' },
|
||||
]
|
||||
|
||||
export interface Settings {
|
||||
backendUrl: string
|
||||
providerId: string
|
||||
modelName: string
|
||||
quality: Quality
|
||||
// 输出 format 的 toggle 集合(screenshot / link 与下方两个布尔保持联动)
|
||||
formats: NoteFormat[]
|
||||
screenshot: boolean
|
||||
link: boolean
|
||||
style: string
|
||||
style: NoteStyle
|
||||
extras: string
|
||||
// 多模态视频理解:抽帧拼图喂给视觉模型,提升画面相关问题的回答质量
|
||||
// 要求所选 model 是视觉模型(如 gpt-4o / gemini / claude-opus 系列),文字模型会忽略图片
|
||||
video_understanding: boolean
|
||||
// 抽帧间隔(秒),范围 1-30,默认 6
|
||||
video_interval: number
|
||||
// 拼图网格 [rows, cols],每张拼图最多 rows*cols 帧。默认 [2,2]
|
||||
grid_size: [number, number]
|
||||
}
|
||||
|
||||
export interface ProviderUpdatePayload {
|
||||
|
||||
@@ -3,9 +3,16 @@ import { onMounted, ref } from 'vue'
|
||||
import { getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import { getModelsByProvider } from '~/logic/api'
|
||||
import type { Model, Provider } from '~/logic/types'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type Model, type NoteFormat, type Provider } from '~/logic/types'
|
||||
import { watch } from 'vue'
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
@@ -128,13 +135,67 @@ onMounted(async () => {
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<input v-model="settings.style" class="input" placeholder="留空使用默认">
|
||||
<select v-model="settings.style" class="input">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">输出形式(与 web 端 NoteForm 对齐)</span>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="input resize-y"
|
||||
rows="3"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">视频理解(多模态)</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
启用后会按抽帧间隔截取视频帧拼成网格图,连同字幕一起喂给视觉模型,提升画面相关问题的回答质量。
|
||||
<strong class="text-amber-700">需要选择视觉模型</strong>(GPT-4o / Gemini / Claude 等),文字模型会忽略图片。
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
启用视频理解
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒, 1-30)</span>
|
||||
<input v-model.number="settings.video_interval" type="number" min="1" max="30" class="input">
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { detectPlatform } from '~/logic/platform'
|
||||
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
import type { TaskRecord } from '~/logic/types'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
||||
|
||||
const tabUrl = ref<string>('')
|
||||
const tabTitle = ref<string>('')
|
||||
@@ -67,19 +67,22 @@ async function start() {
|
||||
try {
|
||||
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie),跳过后端的 download_subtitles 与音频转写
|
||||
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
|
||||
const formats = settings.value.formats || []
|
||||
const { task_id } = await generateNote({
|
||||
video_url: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
quality: settings.value.quality,
|
||||
provider_id: settings.value.providerId,
|
||||
model_name: settings.value.modelName,
|
||||
screenshot: settings.value.screenshot,
|
||||
link: settings.value.link,
|
||||
// backend VideoRequest 同时接受 format 数组与 screenshot/link 单独布尔,从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.value.style || undefined,
|
||||
format: [
|
||||
...(settings.value.screenshot ? ['screenshot'] : []),
|
||||
...(settings.value.link ? ['link'] : []),
|
||||
],
|
||||
extras: settings.value.extras || undefined,
|
||||
video_understanding: settings.value.video_understanding || undefined,
|
||||
video_interval: settings.value.video_understanding ? settings.value.video_interval : undefined,
|
||||
grid_size: settings.value.video_understanding ? settings.value.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
})
|
||||
activeTaskId.value = task_id
|
||||
@@ -108,6 +111,13 @@ function openOptions() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
async function openSidePanel() {
|
||||
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
|
||||
try {
|
||||
@@ -176,7 +186,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
|
||||
@@ -185,14 +195,76 @@ onUnmounted(() => {
|
||||
<option value="slow">高质</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 截图
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.link" type="checkbox"> 跳转
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<select v-model="settings.style" class="border rounded px-1 py-0.5">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-gray-600">输出形式</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">高级</summary>
|
||||
<label class="flex flex-col gap-1 mt-2">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="border rounded px-1 py-1 resize-y"
|
||||
rows="2"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 mt-2">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
<span class="text-gray-600">启用视频理解(抽帧拼图喂视觉模型)</span>
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-2 mt-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒)</span>
|
||||
<input
|
||||
v-model.number="settings.video_interval"
|
||||
type="number" min="1" max="30"
|
||||
class="border rounded px-1 py-0.5"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="settings.video_understanding" class="text-amber-700 mt-1">
|
||||
⚠ 需要选择视觉模型(GPT-4o / Gemini / Claude 等),文字模型会忽略图片
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div class="text-xs text-gray-600">
|
||||
<span v-if="settings.providerId && settings.modelName">
|
||||
模型:{{ settings.modelName }}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use tauri::{Manager, Emitter};
|
||||
use tauri::{Manager, Emitter, State};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use std::env;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use serde::Serialize;
|
||||
|
||||
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
|
||||
struct SidecarHandle(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -18,77 +24,31 @@ pub fn run() {
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
|
||||
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
|
||||
|
||||
// 收集所有系统环境变量
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
// 安装路径诊断:PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸(README 已警告但缺主动防御)
|
||||
// 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动
|
||||
let diag = analyze_install_path(&exe_path);
|
||||
if diag.path_has_non_ascii || diag.path_has_space || !diag.parent_writable {
|
||||
let app_handle = app.handle().clone();
|
||||
// 等前端首屏挂载好 listener;setup 阶段 window 已存在但 React 还没 render
|
||||
// 用独立线程 + 标准 sleep,不引入 tokio 依赖
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("backend-warning", &diag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量,添加常见的二进制路径
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
// 打印一些关键环境变量用于调试
|
||||
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
|
||||
println!("Total environment variables: {}", all_env_vars.len());
|
||||
|
||||
// 检查 ffmpeg 是否在 PATH 中可用
|
||||
check_ffmpeg_availability();
|
||||
|
||||
// 启动 Python 后端侧车
|
||||
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
|
||||
|
||||
// 设置所有环境变量到 sidecar
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, _child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.expect("Failed to spawn sidecar");
|
||||
|
||||
// 获取主窗口句柄用于发送事件
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 读取诸如 stdout 之类的事件
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line);
|
||||
println!("Backend stdout: {}", output);
|
||||
|
||||
// 发送到前端
|
||||
window
|
||||
.emit("backend-message", Some(format!("'{}'", output)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line);
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
|
||||
window
|
||||
.emit("backend-error", Some(format!("'{}'", error)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
window
|
||||
.emit("backend-terminated", Some(payload.code))
|
||||
.expect("failed to emit event");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 启动 Sidecar 并把 child handle 存到 state,方便后续 restart_backend_sidecar 使用
|
||||
let child = spawn_backend_sidecar(app.handle()).map_err(|e| {
|
||||
eprintln!("Sidecar 启动失败: {}", e);
|
||||
e
|
||||
})?;
|
||||
app.manage(SidecarHandle(Mutex::new(Some(child))));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -96,7 +56,9 @@ pub fn run() {
|
||||
get_system_env_vars,
|
||||
find_executable_path,
|
||||
run_command_with_env,
|
||||
test_ffmpeg_access
|
||||
test_ffmpeg_access,
|
||||
get_install_path_diagnostics,
|
||||
restart_backend_sidecar
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@@ -268,6 +230,150 @@ async fn test_ffmpeg_access() -> Result<String, String> {
|
||||
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
|
||||
}
|
||||
|
||||
// 启动后端 Sidecar:负责装环境变量、spawn、挂 stdout/stderr/terminated 监听并 emit 给前端。
|
||||
// 第一次启动 + restart_backend_sidecar 都走这里,保持单一启动路径。
|
||||
fn spawn_backend_sidecar(app_handle: &tauri::AppHandle) -> Result<CommandChild, String> {
|
||||
let exe_path = env::current_exe().map_err(|e| format!("无法获取可执行文件路径: {}", e))?;
|
||||
let sidecar_dir = exe_path
|
||||
.parent()
|
||||
.ok_or("无法获取可执行文件父目录")?
|
||||
.to_path_buf();
|
||||
|
||||
// 收集所有系统环境变量并增强 PATH(含 ffmpeg 常见安装位置)
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
}
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
let mut sidecar_command = app_handle
|
||||
.shell()
|
||||
.sidecar("BiliNoteBackend")
|
||||
.map_err(|e| format!("找不到 BiliNoteBackend sidecar: {}", e))?;
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn sidecar 失败: {}", e))?;
|
||||
|
||||
// 异步监听 stdout / stderr / terminated 事件,转发到前端 webview
|
||||
let app_handle_for_listener = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
// window 句柄每次重新取,允许窗口关闭重开
|
||||
let window = app_handle_for_listener.get_webview_window("main");
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line).to_string();
|
||||
println!("Backend stdout: {}", output);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-message", Some(output));
|
||||
}
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line).to_string();
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-error", Some(error));
|
||||
}
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-terminated", Some(payload.code));
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
// 重启 sidecar:杀旧 child,spawn 新 child,回写到 state。
|
||||
#[tauri::command]
|
||||
fn restart_backend_sidecar(
|
||||
state: State<'_, SidecarHandle>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了)
|
||||
{
|
||||
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
||||
if let Some(child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
// 2. 重新 spawn
|
||||
let new_child = spawn_backend_sidecar(&app)?;
|
||||
{
|
||||
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
||||
*guard = Some(new_child);
|
||||
}
|
||||
// 3. emit 一个事件让前端知道已重启
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.emit("backend-restarted", ());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
|
||||
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
||||
#[derive(Serialize, Clone)]
|
||||
struct InstallPathDiagnostics {
|
||||
exe_path: String,
|
||||
path_has_non_ascii: bool,
|
||||
path_has_space: bool,
|
||||
parent_writable: bool,
|
||||
platform: String,
|
||||
}
|
||||
|
||||
fn analyze_install_path(exe_path: &Path) -> InstallPathDiagnostics {
|
||||
let path_str = exe_path.to_string_lossy().to_string();
|
||||
// 不在 ASCII 范围内的字符(中文 / 日文 / 西里尔等都会命中 PyInstaller 路径解析坑)
|
||||
let has_non_ascii = path_str.chars().any(|c| !c.is_ascii());
|
||||
// 空格本身在 Windows shell 引号场景偶尔出问题,且 macOS path 里也偶尔触发 sidecar 启动失败
|
||||
let has_space = path_str.contains(' ');
|
||||
// 父目录可写:PyInstaller 解压 _internal/、写日志、写配置都需要这个
|
||||
let parent = exe_path.parent();
|
||||
let parent_writable = parent
|
||||
.and_then(|p| {
|
||||
let probe = p.join(".bilinote_write_probe");
|
||||
match std::fs::write(&probe, b"x") {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Some(true)
|
||||
}
|
||||
Err(_) => Some(false),
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
InstallPathDiagnostics {
|
||||
exe_path: path_str,
|
||||
path_has_non_ascii: has_non_ascii,
|
||||
path_has_space: has_space,
|
||||
parent_writable,
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:让前端按需重新查询诊断结果(比如用户卸载到新目录后重启)
|
||||
#[tauri::command]
|
||||
fn get_install_path_diagnostics() -> InstallPathDiagnostics {
|
||||
let exe_path = env::current_exe().unwrap_or_default();
|
||||
analyze_install_path(&exe_path)
|
||||
}
|
||||
|
||||
// 可选:添加一个函数来动态更新 sidecar 的环境变量
|
||||
#[tauri::command]
|
||||
async fn update_sidecar_environment(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
import StartupBanner from '@/components/SystemDiagnostic/StartupBanner'
|
||||
import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
|
||||
@@ -34,6 +36,7 @@ function App() {
|
||||
if (!initialized) {
|
||||
return (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendInitDialog open={loading} />
|
||||
</>
|
||||
)
|
||||
@@ -42,6 +45,8 @@ function App() {
|
||||
// 后端已初始化,渲染主应用
|
||||
return (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendHealthIndicator />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useBackendEvents } from './useBackendEvents'
|
||||
import BackendLogPanel from './BackendLogPanel'
|
||||
|
||||
// 健康度判定:
|
||||
// - 绿:sidecar running 且 /sys_health 通
|
||||
// - 黄:sidecar running 但 /sys_health 失败 (ffmpeg 缺等)
|
||||
// - 红:sidecar terminated 或 /sys_health 连续 3 次失败
|
||||
|
||||
type Health = 'green' | 'yellow' | 'red' | 'unknown'
|
||||
|
||||
const HEALTH_POLL_MS = 5000
|
||||
const SYS_HEALTH_PATH = '/api/sys_health'
|
||||
|
||||
function backendBase(): string {
|
||||
// 与 services/request.ts 用的一致
|
||||
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||
return (fromEnv ?? '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const BackendHealthIndicator = () => {
|
||||
const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [healthCheckFailures, setHealthCheckFailures] = useState(0)
|
||||
const [lastHealthOk, setLastHealthOk] = useState<boolean | null>(null)
|
||||
|
||||
// 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
let mounted = true
|
||||
|
||||
async function ping() {
|
||||
try {
|
||||
const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`)
|
||||
const ok = res.ok
|
||||
if (!mounted) return
|
||||
if (ok) {
|
||||
setHealthCheckFailures(0)
|
||||
setLastHealthOk(true)
|
||||
}
|
||||
else {
|
||||
setHealthCheckFailures(c => c + 1)
|
||||
setLastHealthOk(false)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if (!mounted) return
|
||||
setHealthCheckFailures(c => c + 1)
|
||||
setLastHealthOk(false)
|
||||
}
|
||||
}
|
||||
|
||||
ping()
|
||||
const t = setInterval(ping, HEALTH_POLL_MS)
|
||||
return () => {
|
||||
mounted = false
|
||||
clearInterval(t)
|
||||
}
|
||||
}, [isTauri])
|
||||
|
||||
if (!isTauri) return null
|
||||
|
||||
const health: Health = (() => {
|
||||
if (status === 'terminated') return 'red'
|
||||
if (healthCheckFailures >= 3) return 'red'
|
||||
if (lastHealthOk === false) return 'yellow'
|
||||
if (lastHealthOk === true) return 'green'
|
||||
return 'unknown'
|
||||
})()
|
||||
|
||||
const colorMap: Record<Health, string> = {
|
||||
green: 'bg-green-500',
|
||||
yellow: 'bg-amber-500',
|
||||
red: 'bg-red-500',
|
||||
unknown: 'bg-gray-400',
|
||||
}
|
||||
|
||||
const labelMap: Record<Health, string> = {
|
||||
green: '后端运行正常',
|
||||
yellow: '后端运行中(部分检查未通过)',
|
||||
red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应',
|
||||
unknown: '后端状态未知',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="fixed right-3 bottom-3 z-[9998] flex items-center gap-2 rounded-full border bg-white px-3 py-1.5 text-xs shadow hover:shadow-md"
|
||||
title={labelMap[health]}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${colorMap[health]}${health === 'red' || health === 'yellow' ? ' animate-pulse' : ''}`} />
|
||||
<span className="text-gray-700">后端</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<BackendLogPanel
|
||||
status={status}
|
||||
exitCode={exitCode}
|
||||
logs={logs}
|
||||
health={health}
|
||||
onRestart={restart}
|
||||
onCopyLogs={copyLogs}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendHealthIndicator
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { LogEntry, BackendStatus } from './useBackendEvents'
|
||||
|
||||
interface Props {
|
||||
status: BackendStatus
|
||||
exitCode: number | null
|
||||
logs: LogEntry[]
|
||||
health: 'green' | 'yellow' | 'red' | 'unknown'
|
||||
onRestart: () => Promise<void>
|
||||
onCopyLogs: () => Promise<boolean>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => {
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 新日志进来自动滚到底
|
||||
useEffect(() => {
|
||||
if (scrollRef.current)
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}, [logs])
|
||||
|
||||
async function handleRestart() {
|
||||
setRestarting(true)
|
||||
try { await onRestart() }
|
||||
catch { /* errors already in log via useBackendEvents */ }
|
||||
finally { setRestarting(false) }
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const ok = await onCopyLogs()
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 半透明遮罩 */}
|
||||
<div className="fixed inset-0 z-[9998] bg-black/20" onClick={onClose} />
|
||||
|
||||
<aside className="fixed right-0 bottom-0 top-0 z-[9999] flex w-[480px] max-w-[90vw] flex-col border-l bg-white shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">后端运行状态</h2>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{status === 'terminated'
|
||||
? `已退出(退出码 ${exitCode ?? 'unknown'})`
|
||||
: health === 'red'
|
||||
? '运行中但无响应'
|
||||
: health === 'yellow'
|
||||
? '运行中,部分系统检查未通过'
|
||||
: '运行正常'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded p-1 text-gray-500 hover:bg-gray-100" onClick={onClose}>✕</button>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<button
|
||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={restarting}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{restarting ? '重启中…' : '重启后端'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? '已复制 ✓' : '复制日志'}
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
最近 {logs.length} 行
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-gray-500 italic">暂无日志输出</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div
|
||||
key={`${l.ts}-${i}`}
|
||||
className={`whitespace-pre-wrap break-all leading-snug ${l.level === 'error' ? 'text-red-300' : 'text-gray-100'}`}
|
||||
>
|
||||
<span className="mr-2 text-gray-500">
|
||||
{new Date(l.ts).toISOString().slice(11, 19)}
|
||||
</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-t px-4 py-2 text-xs text-gray-500">
|
||||
后端进程退出 / 无响应时,先点「重启后端」;仍不行复制日志去 issue 反馈。
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendLogPanel
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 桌面端启动诊断横幅。监听 Tauri 侧 emit 的 backend-warning / backend-error / backend-terminated。
|
||||
// 只在 Tauri 环境生效;纯 web 环境(无 window.__TAURI_INTERNALS__)下静默不挂载。
|
||||
|
||||
type Severity = 'info' | 'warning' | 'error'
|
||||
|
||||
interface DiagnosticPayload {
|
||||
exe_path?: string
|
||||
path_has_non_ascii?: boolean
|
||||
path_has_space?: boolean
|
||||
parent_writable?: boolean
|
||||
platform?: string
|
||||
}
|
||||
|
||||
interface BannerState {
|
||||
severity: Severity
|
||||
title: string
|
||||
detail: string
|
||||
payload?: DiagnosticPayload
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
function describeWarning(payload: DiagnosticPayload): { title: string; detail: string } {
|
||||
const parts: string[] = []
|
||||
if (payload.path_has_non_ascii) {
|
||||
parts.push('安装路径包含非 ASCII 字符(中文 / 日文等)')
|
||||
}
|
||||
if (payload.path_has_space) {
|
||||
parts.push('安装路径包含空格')
|
||||
}
|
||||
if (payload.parent_writable === false) {
|
||||
parts.push('安装目录不可写(缺少权限或只读)')
|
||||
}
|
||||
return {
|
||||
title: '检测到可能导致后端启动失败的安装路径',
|
||||
detail:
|
||||
`${parts.join(';')}。\n` +
|
||||
'建议把 BiliNote 重新安装到一个纯英文、无空格、可写的路径下(如 C:\\BiliNote\\ 或 /Applications/)。\n' +
|
||||
`当前路径:${payload.exe_path || '未知'}`,
|
||||
}
|
||||
}
|
||||
|
||||
const StartupBanner = () => {
|
||||
const [banner, setBanner] = useState<BannerState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
;(async () => {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const offWarning = await listen<DiagnosticPayload>('backend-warning', event => {
|
||||
const { title, detail } = describeWarning(event.payload || {})
|
||||
setBanner({
|
||||
severity: 'warning',
|
||||
title,
|
||||
detail,
|
||||
payload: event.payload,
|
||||
dismissible: true,
|
||||
})
|
||||
})
|
||||
|
||||
const offTerminated = await listen<number | null>('backend-terminated', event => {
|
||||
setBanner({
|
||||
severity: 'error',
|
||||
title: '后端进程已退出',
|
||||
detail: `退出码:${event.payload ?? '未知'}。打开「部署监控」或重启应用以恢复。`,
|
||||
dismissible: false,
|
||||
})
|
||||
})
|
||||
|
||||
// backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。
|
||||
unlisteners = [offWarning, offTerminated]
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(fn => fn())
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!banner) return null
|
||||
|
||||
const colorByLevel: Record<Severity, string> = {
|
||||
info: 'bg-blue-50 border-blue-300 text-blue-900',
|
||||
warning: 'bg-amber-50 border-amber-300 text-amber-900',
|
||||
error: 'bg-red-50 border-red-300 text-red-900',
|
||||
}
|
||||
|
||||
const iconByLevel: Record<Severity, string> = {
|
||||
info: 'ℹ️',
|
||||
warning: '⚠️',
|
||||
error: '✕',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed left-0 right-0 top-0 z-[9999] flex items-start gap-3 border-b px-4 py-2 text-sm shadow-sm ${colorByLevel[banner.severity]}`}
|
||||
>
|
||||
<span className="text-lg">{iconByLevel[banner.severity]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{banner.title}</div>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words font-sans text-xs opacity-90">
|
||||
{banner.detail}
|
||||
</pre>
|
||||
</div>
|
||||
{banner.dismissible && (
|
||||
<button
|
||||
className="shrink-0 rounded px-2 py-0.5 text-xs hover:bg-black/10"
|
||||
onClick={() => setBanner(null)}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StartupBanner
|
||||
Reference in New Issue
Block a user