mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-08 09:06:53 +08:00
feat: 优化日志Dialog,支持查看实时日志和文件日志
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"lazysizes": "^5.3.2",
|
"lazysizes": "^5.3.2",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"virtua": "^0.48.6",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"z-vue-scan": "^0.0.35"
|
"z-vue-scan": "^0.0.35"
|
||||||
|
|||||||
Generated
+27
@@ -35,6 +35,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(typescript@5.6.3)(vue@3.5.17(typescript@5.6.3))
|
version: 3.0.3(typescript@5.6.3)(vue@3.5.17(typescript@5.6.3))
|
||||||
|
virtua:
|
||||||
|
specifier: ^0.48.6
|
||||||
|
version: 0.48.6(vue@3.5.17(typescript@5.6.3))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.17(typescript@5.6.3)
|
version: 3.5.17(typescript@5.6.3)
|
||||||
@@ -1843,6 +1846,26 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.11
|
vue: ^3.0.11
|
||||||
|
|
||||||
|
virtua@0.48.6:
|
||||||
|
resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.14.0'
|
||||||
|
react-dom: '>=16.14.0'
|
||||||
|
solid-js: '>=1.0'
|
||||||
|
svelte: '>=5.0'
|
||||||
|
vue: '>=3.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
solid-js:
|
||||||
|
optional: true
|
||||||
|
svelte:
|
||||||
|
optional: true
|
||||||
|
vue:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite-hot-client@2.1.0:
|
vite-hot-client@2.1.0:
|
||||||
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
|
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3816,6 +3839,10 @@ snapshots:
|
|||||||
evtd: 0.2.4
|
evtd: 0.2.4
|
||||||
vue: 3.5.17(typescript@5.6.3)
|
vue: 3.5.17(typescript@5.6.3)
|
||||||
|
|
||||||
|
virtua@0.48.6(vue@3.5.17(typescript@5.6.3)):
|
||||||
|
optionalDependencies:
|
||||||
|
vue: 3.5.17(typescript@5.6.3)
|
||||||
|
|
||||||
vite-hot-client@2.1.0(vite@6.3.5(jiti@2.4.2)):
|
vite-hot-client@2.1.0(vite@6.3.5(jiti@2.4.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 6.3.5(jiti@2.4.2)
|
vite: 6.3.5(jiti@2.4.2)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{BufRead, BufReader},
|
||||||
|
};
|
||||||
|
|
||||||
use eyre::WrapErr;
|
use eyre::WrapErr;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -25,6 +30,7 @@ use crate::{
|
|||||||
get_normal_info_params::GetNormalInfoParams,
|
get_normal_info_params::GetNormalInfoParams,
|
||||||
get_user_video_info_params::GetUserVideoInfoParams,
|
get_user_video_info_params::GetUserVideoInfoParams,
|
||||||
history_info::HistoryInfo,
|
history_info::HistoryInfo,
|
||||||
|
log_metadata::LogMetadata,
|
||||||
normal_info::NormalInfo,
|
normal_info::NormalInfo,
|
||||||
qrcode_data::QrcodeData,
|
qrcode_data::QrcodeData,
|
||||||
qrcode_status::QrcodeStatus,
|
qrcode_status::QrcodeStatus,
|
||||||
@@ -467,3 +473,36 @@ pub async fn get_available_media_formats(
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[specta::specta]
|
||||||
|
#[instrument(level = "error", skip_all, fields(path = path))]
|
||||||
|
pub fn open_log_file(path: &str) -> CommandResult<Vec<LogMetadata>> {
|
||||||
|
let log_file = File::open(path).map_err(|err| CommandError::from("打开日志文件失败", err))?;
|
||||||
|
|
||||||
|
let reader = BufReader::new(log_file);
|
||||||
|
|
||||||
|
let mut logs = Vec::new();
|
||||||
|
let mut line_num = 0;
|
||||||
|
|
||||||
|
for line_result in reader.lines() {
|
||||||
|
line_num += 1;
|
||||||
|
|
||||||
|
let line = line_result
|
||||||
|
.wrap_err(format!("读取日志文件的第`{line_num}`行失败"))
|
||||||
|
.map_err(|err| CommandError::from("打开日志文件失败", err))?;
|
||||||
|
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let log: LogMetadata = serde_json::from_str(&line)
|
||||||
|
.wrap_err(format!("将日志文件的第`{line_num}`行解析为LogMetadata失败"))
|
||||||
|
.map_err(|err| CommandError::from("打开日志文件失败", err))?;
|
||||||
|
|
||||||
|
logs.push(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(logs)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ use tauri::{Manager, Wry};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bili_client::BiliClient,
|
bili_client::BiliClient,
|
||||||
|
commands::open_log_file,
|
||||||
downloader::download_manager::DownloadManager,
|
downloader::download_manager::DownloadManager,
|
||||||
errors::install_custom_eyre_handler,
|
errors::install_custom_eyre_handler,
|
||||||
events::{DownloadEvent, LogEvent},
|
events::{DownloadEvent, LogEvent},
|
||||||
types::log_metadata::LogMetadata,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn generate_context() -> tauri::Context<Wry> {
|
fn generate_context() -> tauri::Context<Wry> {
|
||||||
@@ -72,9 +72,9 @@ pub fn run() {
|
|||||||
show_path_in_file_manager,
|
show_path_in_file_manager,
|
||||||
get_skip_segments,
|
get_skip_segments,
|
||||||
get_available_media_formats,
|
get_available_media_formats,
|
||||||
|
open_log_file,
|
||||||
])
|
])
|
||||||
.events(tauri_specta::collect_events![LogEvent, DownloadEvent])
|
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]);
|
||||||
.typ::<LogMetadata>();
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
builder
|
builder
|
||||||
|
|||||||
+12
-10
@@ -1,5 +1,9 @@
|
|||||||
use std::{io::Write, sync::OnceLock};
|
use std::{io::Write, sync::OnceLock};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
events::LogEvent,
|
||||||
|
extensions::{AppHandleExt, EyreReportToMessage},
|
||||||
|
};
|
||||||
use eyre::{OptionExt, WrapErr};
|
use eyre::{OptionExt, WrapErr};
|
||||||
use notify::{RecommendedWatcher, Watcher};
|
use notify::{RecommendedWatcher, Watcher};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
@@ -13,17 +17,12 @@ use tracing_error::ErrorLayer;
|
|||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
Layer, Registry,
|
Layer, Registry,
|
||||||
filter::{FilterExt, Targets, filter_fn},
|
filter::{FilterExt, Targets, filter_fn},
|
||||||
fmt::{MakeWriter, layer, time::LocalTime},
|
fmt::{MakeWriter, format::JsonFields, layer, time::LocalTime},
|
||||||
layer::SubscriberExt,
|
layer::SubscriberExt,
|
||||||
registry::LookupSpan,
|
registry::LookupSpan,
|
||||||
util::SubscriberInitExt,
|
util::SubscriberInitExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
|
||||||
events::LogEvent,
|
|
||||||
extensions::{AppHandleExt, EyreReportToMessage},
|
|
||||||
};
|
|
||||||
|
|
||||||
struct LogEventWriter {
|
struct LogEventWriter {
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,8 @@ pub fn init(app: &AppHandle) -> eyre::Result<()> {
|
|||||||
.with_writer(std::io::stdout)
|
.with_writer(std::io::stdout)
|
||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true);
|
.with_line_number(true)
|
||||||
|
.pretty();
|
||||||
// 发送到前端
|
// 发送到前端
|
||||||
let log_event_factory = LogEventWriterFactory { app: app.clone() };
|
let log_event_factory = LogEventWriterFactory { app: app.clone() };
|
||||||
let log_event_layer = layer()
|
let log_event_layer = layer()
|
||||||
@@ -92,7 +92,7 @@ pub fn init(app: &AppHandle) -> eyre::Result<()> {
|
|||||||
.with(reloadable_file_layer)
|
.with(reloadable_file_layer)
|
||||||
.with(console_layer)
|
.with(console_layer)
|
||||||
.with(log_event_layer)
|
.with(log_event_layer)
|
||||||
.with(ErrorLayer::default())
|
.with(ErrorLayer::new(JsonFields::default()))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
|
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
|
||||||
@@ -138,7 +138,8 @@ where
|
|||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true);
|
.with_line_number(true)
|
||||||
|
.json();
|
||||||
return Ok((Box::new(sink_layer), None));
|
return Ok((Box::new(sink_layer), None));
|
||||||
}
|
}
|
||||||
let logs_dir = logs_dir(app).wrap_err("获取日志目录失败")?;
|
let logs_dir = logs_dir(app).wrap_err("获取日志目录失败")?;
|
||||||
@@ -154,7 +155,8 @@ where
|
|||||||
.with_timer(LocalTime::rfc_3339())
|
.with_timer(LocalTime::rfc_3339())
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true);
|
.with_line_number(true)
|
||||||
|
.json();
|
||||||
Ok((Box::new(file_layer), Some(guard)))
|
Ok((Box::new(file_layer), Some(guard)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ async getAvailableMediaFormats(params: GetAvailableMediaFormatsParams) : Promise
|
|||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async openLogFile(path: string) : Promise<Result<LogMetadata[], CommandError>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("open_log_file", { path }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+414
-121
@@ -1,24 +1,54 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { LogLevel, events, commands, LogMetadata } from '../bindings.ts'
|
import { commands, events, JsonValue, LogLevel, LogMetadata } from '../bindings.ts'
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NCheckbox,
|
NCheckbox,
|
||||||
NConfigProvider,
|
|
||||||
NDialog,
|
NDialog,
|
||||||
NInput,
|
NInput,
|
||||||
NInputGroup,
|
NInputGroup,
|
||||||
NModal,
|
NModal,
|
||||||
NSelect,
|
NSelect,
|
||||||
NVirtualList,
|
NTag,
|
||||||
useNotification,
|
useNotification,
|
||||||
|
NIcon,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { onMounted, ref, watch, computed, shallowRef, triggerRef } from 'vue'
|
import {
|
||||||
import { appDataDir } from '@tauri-apps/api/path'
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
PropType,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
triggerRef,
|
||||||
|
useTemplateRef,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { appDataDir, basename } from '@tauri-apps/api/path'
|
||||||
import { path } from '@tauri-apps/api'
|
import { path } from '@tauri-apps/api'
|
||||||
import { useStore } from '../store.ts'
|
import { useStore } from '../store.ts'
|
||||||
import { darkTheme } from 'naive-ui'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { VList } from 'virtua/vue'
|
||||||
|
import { PhArrowDown, PhArrowUp } from '@phosphor-icons/vue'
|
||||||
|
|
||||||
type LogRecord = LogMetadata & { id: number; formatedLog: string }
|
export type LogField = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogRecord = LogMetadata & {
|
||||||
|
id: number
|
||||||
|
textForFilter: string
|
||||||
|
renderData: {
|
||||||
|
message: string
|
||||||
|
extraFields: LogField[]
|
||||||
|
spanLines?: Array<{
|
||||||
|
name: string
|
||||||
|
args: LogField[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
@@ -28,39 +58,28 @@ const showing = defineModel<boolean>('showing', { required: true })
|
|||||||
|
|
||||||
let nextLogRecordId = 1
|
let nextLogRecordId = 1
|
||||||
|
|
||||||
const logRecords = shallowRef<LogRecord[]>([])
|
const logLevelOptions = [
|
||||||
const searchText = ref<string>('')
|
{ value: 'TRACE', label: 'TRACE' },
|
||||||
|
{ value: 'DEBUG', label: 'DEBUG' },
|
||||||
|
{ value: 'INFO', label: 'INFO' },
|
||||||
|
{ value: 'WARN', label: 'WARN' },
|
||||||
|
{ value: 'ERROR', label: 'ERROR' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const vListRef = useTemplateRef('vListRef')
|
||||||
|
const isAtTop = ref<boolean>(false)
|
||||||
|
const isAtBottom = ref<boolean>(false)
|
||||||
|
|
||||||
|
const viewMode = ref<'live' | 'file'>('live')
|
||||||
|
const currentFileName = ref<string>('')
|
||||||
|
|
||||||
|
const liveLogRecords = shallowRef<LogRecord[]>([])
|
||||||
|
const fileLogRecords = shallowRef<LogRecord[]>([])
|
||||||
|
|
||||||
|
const filterText = ref<string>('')
|
||||||
const selectedLevel = ref<LogLevel>('INFO')
|
const selectedLevel = ref<LogLevel>('INFO')
|
||||||
const logsDirSize = ref<number>(0)
|
const logsDirSize = ref<number>(0)
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const result = await commands.getLogsDirSize()
|
|
||||||
if (result.status === 'error') {
|
|
||||||
console.error(result.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 检查日志目录大小
|
|
||||||
if (result.data > 50 * 1024 * 1024) {
|
|
||||||
notification.warning({
|
|
||||||
title: '日志目录大小超过50MB,请及时清理日志文件',
|
|
||||||
description: () => (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
点击左下角的 <span class="bg-gray-2 px-1">日志</span> 按钮
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
里边有 <span class="bg-gray-2 px-1">打开日志目录</span> 按钮
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
你也可以在里边取消勾选 <span class="bg-gray-2 px-1">输出文件日志</span>
|
|
||||||
</div>
|
|
||||||
<div>这样将不再产生文件日志</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatedLogsDirSize = computed<string>(() => {
|
const formatedLogsDirSize = computed<string>(() => {
|
||||||
const units = ['B', 'KB', 'MB']
|
const units = ['B', 'KB', 'MB']
|
||||||
let size = logsDirSize.value
|
let size = logsDirSize.value
|
||||||
@@ -74,9 +93,12 @@ const formatedLogsDirSize = computed<string>(() => {
|
|||||||
// 保留两位小数
|
// 保留两位小数
|
||||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredLogs = computed<LogRecord[]>(() => {
|
const filteredLogs = computed<LogRecord[]>(() => {
|
||||||
return logRecords.value.filter(({ level, formatedLog }) => {
|
// 根据模式选择数据源
|
||||||
// 定义日志等级的优先级顺序
|
const sourceRecords = viewMode.value === 'live' ? liveLogRecords.value : fileLogRecords.value
|
||||||
|
|
||||||
|
return sourceRecords.filter(({ level, textForFilter }) => {
|
||||||
const logLevelPriority = {
|
const logLevelPriority = {
|
||||||
TRACE: 0,
|
TRACE: 0,
|
||||||
DEBUG: 1,
|
DEBUG: 1,
|
||||||
@@ -88,15 +110,48 @@ const filteredLogs = computed<LogRecord[]>(() => {
|
|||||||
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
|
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 然后按搜索文本筛选
|
// 然后按过滤文本筛选
|
||||||
if (searchText.value === '') {
|
if (filterText.value === '') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatedLog.toLowerCase().includes(searchText.value.toLowerCase())
|
return textForFilter.toLowerCase().includes(filterText.value.toLowerCase())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const result = await commands.getLogsDirSize()
|
||||||
|
if (result.status === 'error') {
|
||||||
|
console.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 检查日志目录大小
|
||||||
|
if (result.data > 50 * 1024 * 1024) {
|
||||||
|
notification.warning({
|
||||||
|
title: '日志目录大小超过50MB,请及时清理日志文件',
|
||||||
|
description: () => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
点击右上角的 <span class="bg-gray-2 px-1">日志</span> 按钮
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
里边有 <span class="bg-gray-2 px-1">打开日志目录</span> 按钮
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
你也可以在里边取消勾选 <span class="bg-gray-2 px-1">输出文件日志</span>
|
||||||
|
</div>
|
||||||
|
<div>这样将不再产生文件日志</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filteredLogs, async () => {
|
||||||
|
await nextTick()
|
||||||
|
updateScrollEdgeState()
|
||||||
|
})
|
||||||
|
|
||||||
watch(showing, async () => {
|
watch(showing, async () => {
|
||||||
if (showing.value) {
|
if (showing.value) {
|
||||||
const result = await commands.getLogsDirSize()
|
const result = await commands.getLogsDirSize()
|
||||||
@@ -108,65 +163,88 @@ watch(showing, async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
let unListenLogEvent: () => void | undefined
|
||||||
await events.logEvent.listen(async ({ payload: logEvent }) => {
|
onMounted(() => {
|
||||||
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
|
events.logEvent
|
||||||
|
.listen(({ payload: logEvent }) => {
|
||||||
|
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
|
||||||
|
|
||||||
const logRecord: LogRecord = {
|
const logRecord = logMetadataToLogRecord(logMetadata)
|
||||||
...logMetadata,
|
liveLogRecords.value.push(logRecord)
|
||||||
id: nextLogRecordId++,
|
triggerRef(liveLogRecords)
|
||||||
formatedLog: formatLogMetadata(logMetadata),
|
|
||||||
}
|
|
||||||
|
|
||||||
logRecords.value.push(logRecord)
|
if (logRecord.level === 'ERROR') {
|
||||||
triggerRef(logRecords)
|
notification.error({
|
||||||
|
title: (logRecord.fields['err_title'] as string) || 'Error',
|
||||||
const { level, fields } = logMetadata
|
description: (logRecord.fields['message'] as string) || 'Unknown Error',
|
||||||
if (level === 'ERROR') {
|
duration: 0,
|
||||||
notification.error({
|
})
|
||||||
title: fields['err_title'] as string,
|
}
|
||||||
description: fields['message'] as string,
|
})
|
||||||
duration: 0,
|
.then((unListenFn) => {
|
||||||
})
|
unListenLogEvent = unListenFn
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
unListenLogEvent?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatLogMetadata(logMetadata: LogMetadata): string {
|
function formatJsonValue(jsonValue: JsonValue): string {
|
||||||
const { timestamp, level, fields, target, filename, line_number } = logMetadata
|
if (Array.isArray(jsonValue)) return `[${jsonValue.map(formatJsonValue).join(', ')}]`
|
||||||
const fields_str = Object.entries(fields)
|
if (typeof jsonValue === 'object' && jsonValue !== null)
|
||||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
return `{${Object.entries(jsonValue)
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.map(([k, v]) => `${k}: ${formatJsonValue(v)}`)
|
||||||
.join(' ')
|
.join(', ')}}`
|
||||||
return `${timestamp} ${level} ${target}: ${filename}:${line_number} ${fields_str}`
|
return typeof jsonValue === 'string' ? `"${jsonValue}"` : String(jsonValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLevelStyles(level: LogLevel) {
|
function logMetadataToLogRecord(meta: LogMetadata): LogRecord {
|
||||||
switch (level) {
|
const message = meta.fields['message'] as string
|
||||||
case 'TRACE':
|
|
||||||
return 'text-gray-400'
|
const extraFields = Object.entries(meta.fields)
|
||||||
case 'DEBUG':
|
.filter(([key]) => key !== 'message')
|
||||||
return 'text-green-400'
|
.map(([key, jsonValue]) => ({
|
||||||
case 'INFO':
|
key,
|
||||||
return 'text-blue-400'
|
value: formatJsonValue(jsonValue),
|
||||||
case 'WARN':
|
}))
|
||||||
return 'text-yellow-400'
|
|
||||||
case 'ERROR':
|
const spanLines = meta.spans
|
||||||
return 'text-red-400'
|
?.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((span) => {
|
||||||
|
const args = Object.entries(span)
|
||||||
|
.filter(([key]) => key !== 'name')
|
||||||
|
.map(([key, jsonValue]) => ({
|
||||||
|
key,
|
||||||
|
value: formatJsonValue(jsonValue),
|
||||||
|
}))
|
||||||
|
return { name: span.name, args }
|
||||||
|
})
|
||||||
|
|
||||||
|
const extraFieldsStr = extraFields.map((f) => `${f.key}: ${f.value}`).join(', ')
|
||||||
|
const headerLine = `${meta.timestamp} ${meta.level} ${meta.target}: ${message} ${extraFieldsStr}`
|
||||||
|
|
||||||
|
const locationLine = `at ${meta.filename}:${meta.line_number}`
|
||||||
|
|
||||||
|
const contextLines = spanLines
|
||||||
|
?.map((s) => {
|
||||||
|
const argsStr = s.args.map((a) => `${a.key}: ${a.value}`).join(', ')
|
||||||
|
return `in ${s.name} ${argsStr}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const textForFilter = `${headerLine}\n${locationLine}\n${contextLines}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
id: nextLogRecordId++,
|
||||||
|
textForFilter,
|
||||||
|
renderData: { message, extraFields, spanLines },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logLevelOptions = [
|
function clearLiveLogRecords() {
|
||||||
{ value: 'TRACE', label: 'TRACE' },
|
liveLogRecords.value = []
|
||||||
{ value: 'DEBUG', label: 'DEBUG' },
|
|
||||||
{ value: 'INFO', label: 'INFO' },
|
|
||||||
{ value: 'WARN', label: 'WARN' },
|
|
||||||
{ value: 'ERROR', label: 'ERROR' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function clearLogRecords() {
|
|
||||||
logRecords.value = []
|
|
||||||
nextLogRecordId = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showLogsDirInFileManager() {
|
async function showLogsDirInFileManager() {
|
||||||
@@ -176,44 +254,259 @@ async function showLogsDirInFileManager() {
|
|||||||
console.error(result.error)
|
console.error(result.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openLogFile() {
|
||||||
|
const logsDir = await path.join(await appDataDir(), '日志')
|
||||||
|
|
||||||
|
const selectedFilePath = await open({
|
||||||
|
defaultPath: logsDir,
|
||||||
|
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selectedFilePath === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await commands.openLogFile(selectedFilePath)
|
||||||
|
if (result.status === 'error') {
|
||||||
|
console.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogRecords.value = result.data.map(logMetadataToLogRecord)
|
||||||
|
currentFileName.value = await basename(selectedFilePath)
|
||||||
|
|
||||||
|
viewMode.value = 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFileMode() {
|
||||||
|
viewMode.value = 'live'
|
||||||
|
currentFileName.value = ''
|
||||||
|
fileLogRecords.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToTop() {
|
||||||
|
vListRef.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToBottom() {
|
||||||
|
vListRef.value?.scrollToIndex(filteredLogs.value.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollEdgeState() {
|
||||||
|
if (vListRef.value === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollOffset, scrollSize, viewportSize } = vListRef.value
|
||||||
|
|
||||||
|
const threshold = 50
|
||||||
|
|
||||||
|
isAtTop.value = scrollOffset <= threshold
|
||||||
|
|
||||||
|
isAtBottom.value = scrollOffset + viewportSize >= scrollSize - threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogRecordComponent = defineComponent({
|
||||||
|
name: 'LogRecordComponent',
|
||||||
|
props: {
|
||||||
|
logRecord: {
|
||||||
|
type: Object as PropType<LogRecord>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const levelTextClass = computed(() => {
|
||||||
|
switch (props.logRecord.level) {
|
||||||
|
case 'TRACE':
|
||||||
|
return 'text-fuchsia-400'
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'text-blue-400'
|
||||||
|
case 'INFO':
|
||||||
|
return 'text-green-400'
|
||||||
|
case 'WARN':
|
||||||
|
return 'text-amber-400'
|
||||||
|
case 'ERROR':
|
||||||
|
return 'text-red-400'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelBoldClass = computed(() => {
|
||||||
|
switch (props.logRecord.level) {
|
||||||
|
case 'TRACE':
|
||||||
|
return 'font-bold text-fuchsia-600'
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'font-bold text-blue-600'
|
||||||
|
case 'INFO':
|
||||||
|
return 'font-bold text-green-600'
|
||||||
|
case 'WARN':
|
||||||
|
return 'font-bold text-amber-600'
|
||||||
|
case 'ERROR':
|
||||||
|
return 'font-bold text-red-600'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelTagClass = computed(() => {
|
||||||
|
switch (props.logRecord.level) {
|
||||||
|
case 'TRACE':
|
||||||
|
return 'rounded-md px-1 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 border-solid border-2 border-fuchsia-500/30'
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'rounded-md px-1 py-0.5 bg-blue-500/20 text-blue-300 border-solid border-2 border-blue-500/30'
|
||||||
|
case 'INFO':
|
||||||
|
return 'rounded-md px-1 py-0.5 bg-green-500/20 text-green-300 border-solid border-2 border-green-500/30'
|
||||||
|
case 'WARN':
|
||||||
|
return 'rounded-md px-1 py-0.5 bg-amber-500/20 text-amber-300 border-solid border-2 border-amber-500/30'
|
||||||
|
case 'ERROR':
|
||||||
|
return 'rounded-md px-1 py-0.5 bg-red-500/20 text-red-300 border-solid border-2 border-red-500/30'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="py-1 px-3 hover:bg-white/5 whitespace-pre-wrap break-all">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 whitespace-nowrap">{props.logRecord.timestamp}</span>
|
||||||
|
<span> </span>
|
||||||
|
<span class={levelTextClass.value}>
|
||||||
|
<span class={levelTagClass.value}>{props.logRecord.level}</span>
|
||||||
|
<span> </span>
|
||||||
|
<span class={levelBoldClass.value}>{props.logRecord.target}:</span>
|
||||||
|
<span> </span>
|
||||||
|
<span>{props.logRecord.renderData.message}</span>
|
||||||
|
{props.logRecord.renderData.extraFields.length > 0 && (
|
||||||
|
<span>
|
||||||
|
<span>{', '}</span>
|
||||||
|
{props.logRecord.renderData.extraFields.map(({ key, value }, i) => (
|
||||||
|
<span>
|
||||||
|
{i > 0 && <span>{', '}</span>}
|
||||||
|
<span class={levelBoldClass.value}>{key}</span>
|
||||||
|
<span>{': '}</span>
|
||||||
|
<span class="text-orange-300">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-300">
|
||||||
|
<span>{' '}</span>
|
||||||
|
<span class="text-gray-500">at</span>
|
||||||
|
<span> </span>
|
||||||
|
<span>
|
||||||
|
{props.logRecord.filename}:{props.logRecord.line_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.logRecord.renderData.spanLines?.map((span, idx) => (
|
||||||
|
<div key={idx} class="text-gray-300">
|
||||||
|
<span>{' '}</span>
|
||||||
|
<span class="text-gray-500">in</span>
|
||||||
|
<span> </span>
|
||||||
|
<span class="font-bold text-indigo-300">{span.name}</span>
|
||||||
|
{span.args.length > 0 && (
|
||||||
|
<span>
|
||||||
|
<span> </span>
|
||||||
|
<span class="text-gray-500">with</span>
|
||||||
|
<span> </span>
|
||||||
|
{span.args.map((arg, i) => (
|
||||||
|
<span>
|
||||||
|
{i > 0 && <span>{', '}</span>}
|
||||||
|
<span class="font-bold text-gray-300">{arg.key}</span>
|
||||||
|
<span>: </span>
|
||||||
|
<span class="text-orange-300">{arg.value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal v-model:show="showing" v-if="store.config !== undefined">
|
<n-modal v-model:show="showing" v-if="store.config !== undefined">
|
||||||
<n-dialog
|
<n-dialog :showIcon="false" @close="showing = false" style="width: 95%">
|
||||||
:showIcon="false"
|
<template #header>
|
||||||
:title="`日志目录总大小:${formatedLogsDirSize}`"
|
<div class="text-lg font-bold flex items-center gap-2">
|
||||||
@close="showing = false"
|
<span v-if="viewMode === 'live'">📡 实时日志</span>
|
||||||
style="width: 95%">
|
<span v-else>
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
📂 文件日志
|
||||||
<n-input-group class="w-100">
|
<n-tag class="ml-2" type="primary" size="small">
|
||||||
<n-input size="small" v-model:value="searchText" placeholder="搜素日志..." clearable />
|
{{ currentFileName }}
|
||||||
<n-select size="small" v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
|
</n-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-2 flex flex-wrap">
|
||||||
|
<n-input-group class="flex-1 mr-4">
|
||||||
|
<n-input v-model:value="filterText" placeholder="关键词过滤..." clearable />
|
||||||
|
<n-select v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
|
||||||
</n-input-group>
|
</n-input-group>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 ml-auto items-center">
|
<n-button v-if="viewMode === 'file'" class="mr-2" type="primary" secondary @click="exitFileMode">
|
||||||
<n-button size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
返回实时日志
|
||||||
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
</n-button>
|
||||||
|
|
||||||
|
<n-button type="primary" @click="openLogFile">打开日志文件</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-[calc(100vh-250px)]!">
|
||||||
|
<VList
|
||||||
|
ref="vListRef"
|
||||||
|
class="h-full overflow-hidden bg-gray-950 text-sm"
|
||||||
|
:data="filteredLogs"
|
||||||
|
@scroll="updateScrollEdgeState"
|
||||||
|
#default="{ item }: { item: LogRecord }">
|
||||||
|
<LogRecordComponent :key="item.id" :logRecord="item" />
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div v-show="isAtTop === false" class="absolute top-6 right-6">
|
||||||
|
<n-button circle type="primary" class="opacity-30 hover:opacity-100 transition-opacity" @click="jumpToTop">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<PhArrowUp />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="isAtBottom === false" class="absolute bottom-6 right-6">
|
||||||
|
<n-button circle type="primary" class="opacity-30 hover:opacity-100 transition-opacity" @click="jumpToBottom">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<PhArrowDown />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-config-provider :theme="darkTheme" :theme-overrides="{ Scrollbar: { width: '8px' } }">
|
<div class="pt-2 flex flex-wrap items-center">
|
||||||
<n-virtual-list
|
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
||||||
class="h-[calc(100vh-300px)] overflow-hidden bg-gray-900"
|
<n-button class="ml-2" size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
||||||
:item-size="42"
|
<n-tag class="ml-1" size="small" :bordered="false">
|
||||||
item-resizable
|
{{ formatedLogsDirSize }}
|
||||||
:hoverable="false"
|
</n-tag>
|
||||||
:items="filteredLogs"
|
|
||||||
:scrollbar-props="{ trigger: 'none' }">
|
<n-button
|
||||||
<template #default="{ item: { level, formatedLog } }: { item: LogRecord }">
|
v-if="viewMode === 'live'"
|
||||||
<div :class="['py-1 px-3 hover:bg-white/10 whitespace-pre-wrap mr-4', getLevelStyles(level)]">
|
ghost
|
||||||
{{ formatedLog }}
|
class="ml-auto"
|
||||||
</div>
|
size="small"
|
||||||
</template>
|
type="error"
|
||||||
</n-virtual-list>
|
@click="clearLiveLogRecords">
|
||||||
</n-config-provider>
|
清空实时日志
|
||||||
<div class="pt-1 flex">
|
</n-button>
|
||||||
<n-button ghost class="ml-auto" size="small" type="error" @click="clearLogRecords">清空日志浏览器</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</n-dialog>
|
</n-dialog>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user