mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-05-06 20:02:57 +08:00
feat: 优化日志Dialog,支持查看实时日志和文件日志
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"lazysizes": "^5.3.2",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^3.0.3",
|
||||
"virtua": "^0.48.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"z-vue-scan": "^0.0.35"
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.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:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.17(typescript@5.6.3)
|
||||
@@ -1843,6 +1846,26 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
|
||||
peerDependencies:
|
||||
@@ -3816,6 +3839,10 @@ snapshots:
|
||||
evtd: 0.2.4
|
||||
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)):
|
||||
dependencies:
|
||||
vite: 6.3.5(jiti@2.4.2)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use parking_lot::RwLock;
|
||||
use tauri::AppHandle;
|
||||
@@ -25,6 +30,7 @@ use crate::{
|
||||
get_normal_info_params::GetNormalInfoParams,
|
||||
get_user_video_info_params::GetUserVideoInfoParams,
|
||||
history_info::HistoryInfo,
|
||||
log_metadata::LogMetadata,
|
||||
normal_info::NormalInfo,
|
||||
qrcode_data::QrcodeData,
|
||||
qrcode_status::QrcodeStatus,
|
||||
@@ -467,3 +473,36 @@ pub async fn get_available_media_formats(
|
||||
|
||||
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::{
|
||||
bili_client::BiliClient,
|
||||
commands::open_log_file,
|
||||
downloader::download_manager::DownloadManager,
|
||||
errors::install_custom_eyre_handler,
|
||||
events::{DownloadEvent, LogEvent},
|
||||
types::log_metadata::LogMetadata,
|
||||
};
|
||||
|
||||
fn generate_context() -> tauri::Context<Wry> {
|
||||
@@ -72,9 +72,9 @@ pub fn run() {
|
||||
show_path_in_file_manager,
|
||||
get_skip_segments,
|
||||
get_available_media_formats,
|
||||
open_log_file,
|
||||
])
|
||||
.events(tauri_specta::collect_events![LogEvent, DownloadEvent])
|
||||
.typ::<LogMetadata>();
|
||||
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
builder
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::{io::Write, sync::OnceLock};
|
||||
|
||||
use crate::{
|
||||
events::LogEvent,
|
||||
extensions::{AppHandleExt, EyreReportToMessage},
|
||||
};
|
||||
use eyre::{OptionExt, WrapErr};
|
||||
use notify::{RecommendedWatcher, Watcher};
|
||||
use tauri::{AppHandle, Manager};
|
||||
@@ -13,17 +17,12 @@ use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::{
|
||||
Layer, Registry,
|
||||
filter::{FilterExt, Targets, filter_fn},
|
||||
fmt::{MakeWriter, layer, time::LocalTime},
|
||||
fmt::{MakeWriter, format::JsonFields, layer, time::LocalTime},
|
||||
layer::SubscriberExt,
|
||||
registry::LookupSpan,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
events::LogEvent,
|
||||
extensions::{AppHandleExt, EyreReportToMessage},
|
||||
};
|
||||
|
||||
struct LogEventWriter {
|
||||
app: AppHandle,
|
||||
}
|
||||
@@ -73,7 +72,8 @@ pub fn init(app: &AppHandle) -> eyre::Result<()> {
|
||||
.with_writer(std::io::stdout)
|
||||
.with_timer(LocalTime::rfc_3339())
|
||||
.with_file(true)
|
||||
.with_line_number(true);
|
||||
.with_line_number(true)
|
||||
.pretty();
|
||||
// 发送到前端
|
||||
let log_event_factory = LogEventWriterFactory { app: app.clone() };
|
||||
let log_event_layer = layer()
|
||||
@@ -92,7 +92,7 @@ pub fn init(app: &AppHandle) -> eyre::Result<()> {
|
||||
.with(reloadable_file_layer)
|
||||
.with(console_layer)
|
||||
.with(log_event_layer)
|
||||
.with(ErrorLayer::default())
|
||||
.with(ErrorLayer::new(JsonFields::default()))
|
||||
.init();
|
||||
|
||||
GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
|
||||
@@ -138,7 +138,8 @@ where
|
||||
.with_timer(LocalTime::rfc_3339())
|
||||
.with_ansi(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true);
|
||||
.with_line_number(true)
|
||||
.json();
|
||||
return Ok((Box::new(sink_layer), None));
|
||||
}
|
||||
let logs_dir = logs_dir(app).wrap_err("获取日志目录失败")?;
|
||||
@@ -154,7 +155,8 @@ where
|
||||
.with_timer(LocalTime::rfc_3339())
|
||||
.with_ansi(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true);
|
||||
.with_line_number(true)
|
||||
.json();
|
||||
Ok((Box::new(file_layer), Some(guard)))
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,14 @@ async getAvailableMediaFormats(params: GetAvailableMediaFormatsParams) : Promise
|
||||
if(e instanceof Error) throw e;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
<script setup lang="tsx">
|
||||
import { LogLevel, events, commands, LogMetadata } from '../bindings.ts'
|
||||
import { commands, events, JsonValue, LogLevel, LogMetadata } from '../bindings.ts'
|
||||
import {
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NConfigProvider,
|
||||
NDialog,
|
||||
NInput,
|
||||
NInputGroup,
|
||||
NModal,
|
||||
NSelect,
|
||||
NVirtualList,
|
||||
NTag,
|
||||
useNotification,
|
||||
NIcon,
|
||||
} from 'naive-ui'
|
||||
import { onMounted, ref, watch, computed, shallowRef, triggerRef } from 'vue'
|
||||
import { appDataDir } from '@tauri-apps/api/path'
|
||||
import {
|
||||
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 { 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()
|
||||
|
||||
@@ -28,39 +58,28 @@ const showing = defineModel<boolean>('showing', { required: true })
|
||||
|
||||
let nextLogRecordId = 1
|
||||
|
||||
const logRecords = shallowRef<LogRecord[]>([])
|
||||
const searchText = ref<string>('')
|
||||
const logLevelOptions = [
|
||||
{ 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 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 units = ['B', 'KB', 'MB']
|
||||
let size = logsDirSize.value
|
||||
@@ -74,9 +93,12 @@ const formatedLogsDirSize = computed<string>(() => {
|
||||
// 保留两位小数
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
})
|
||||
|
||||
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 = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
@@ -88,15 +110,48 @@ const filteredLogs = computed<LogRecord[]>(() => {
|
||||
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
|
||||
return false
|
||||
}
|
||||
// 然后按搜索文本筛选
|
||||
if (searchText.value === '') {
|
||||
// 然后按过滤文本筛选
|
||||
if (filterText.value === '') {
|
||||
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 () => {
|
||||
if (showing.value) {
|
||||
const result = await commands.getLogsDirSize()
|
||||
@@ -108,65 +163,88 @@ watch(showing, async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await events.logEvent.listen(async ({ payload: logEvent }) => {
|
||||
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
|
||||
let unListenLogEvent: () => void | undefined
|
||||
onMounted(() => {
|
||||
events.logEvent
|
||||
.listen(({ payload: logEvent }) => {
|
||||
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw)
|
||||
|
||||
const logRecord: LogRecord = {
|
||||
...logMetadata,
|
||||
id: nextLogRecordId++,
|
||||
formatedLog: formatLogMetadata(logMetadata),
|
||||
}
|
||||
const logRecord = logMetadataToLogRecord(logMetadata)
|
||||
liveLogRecords.value.push(logRecord)
|
||||
triggerRef(liveLogRecords)
|
||||
|
||||
logRecords.value.push(logRecord)
|
||||
triggerRef(logRecords)
|
||||
|
||||
const { level, fields } = logMetadata
|
||||
if (level === 'ERROR') {
|
||||
notification.error({
|
||||
title: fields['err_title'] as string,
|
||||
description: fields['message'] as string,
|
||||
duration: 0,
|
||||
})
|
||||
}
|
||||
})
|
||||
if (logRecord.level === 'ERROR') {
|
||||
notification.error({
|
||||
title: (logRecord.fields['err_title'] as string) || 'Error',
|
||||
description: (logRecord.fields['message'] as string) || 'Unknown Error',
|
||||
duration: 0,
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((unListenFn) => {
|
||||
unListenLogEvent = unListenFn
|
||||
})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unListenLogEvent?.()
|
||||
})
|
||||
|
||||
function formatLogMetadata(logMetadata: LogMetadata): string {
|
||||
const { timestamp, level, fields, target, filename, line_number } = logMetadata
|
||||
const fields_str = Object.entries(fields)
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(' ')
|
||||
return `${timestamp} ${level} ${target}: ${filename}:${line_number} ${fields_str}`
|
||||
function formatJsonValue(jsonValue: JsonValue): string {
|
||||
if (Array.isArray(jsonValue)) return `[${jsonValue.map(formatJsonValue).join(', ')}]`
|
||||
if (typeof jsonValue === 'object' && jsonValue !== null)
|
||||
return `{${Object.entries(jsonValue)
|
||||
.map(([k, v]) => `${k}: ${formatJsonValue(v)}`)
|
||||
.join(', ')}}`
|
||||
return typeof jsonValue === 'string' ? `"${jsonValue}"` : String(jsonValue)
|
||||
}
|
||||
|
||||
function getLevelStyles(level: LogLevel) {
|
||||
switch (level) {
|
||||
case 'TRACE':
|
||||
return 'text-gray-400'
|
||||
case 'DEBUG':
|
||||
return 'text-green-400'
|
||||
case 'INFO':
|
||||
return 'text-blue-400'
|
||||
case 'WARN':
|
||||
return 'text-yellow-400'
|
||||
case 'ERROR':
|
||||
return 'text-red-400'
|
||||
function logMetadataToLogRecord(meta: LogMetadata): LogRecord {
|
||||
const message = meta.fields['message'] as string
|
||||
|
||||
const extraFields = Object.entries(meta.fields)
|
||||
.filter(([key]) => key !== 'message')
|
||||
.map(([key, jsonValue]) => ({
|
||||
key,
|
||||
value: formatJsonValue(jsonValue),
|
||||
}))
|
||||
|
||||
const spanLines = meta.spans
|
||||
?.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 = [
|
||||
{ value: 'TRACE', label: 'TRACE' },
|
||||
{ value: 'DEBUG', label: 'DEBUG' },
|
||||
{ value: 'INFO', label: 'INFO' },
|
||||
{ value: 'WARN', label: 'WARN' },
|
||||
{ value: 'ERROR', label: 'ERROR' },
|
||||
]
|
||||
|
||||
function clearLogRecords() {
|
||||
logRecords.value = []
|
||||
nextLogRecordId = 1
|
||||
function clearLiveLogRecords() {
|
||||
liveLogRecords.value = []
|
||||
}
|
||||
|
||||
async function showLogsDirInFileManager() {
|
||||
@@ -176,44 +254,259 @@ async function showLogsDirInFileManager() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="showing" v-if="store.config !== undefined">
|
||||
<n-dialog
|
||||
:showIcon="false"
|
||||
:title="`日志目录总大小:${formatedLogsDirSize}`"
|
||||
@close="showing = false"
|
||||
style="width: 95%">
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<n-input-group class="w-100">
|
||||
<n-input size="small" v-model:value="searchText" placeholder="搜素日志..." clearable />
|
||||
<n-select size="small" v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
|
||||
<n-dialog :showIcon="false" @close="showing = false" style="width: 95%">
|
||||
<template #header>
|
||||
<div class="text-lg font-bold flex items-center gap-2">
|
||||
<span v-if="viewMode === 'live'">📡 实时日志</span>
|
||||
<span v-else>
|
||||
📂 文件日志
|
||||
<n-tag class="ml-2" type="primary" size="small">
|
||||
{{ currentFileName }}
|
||||
</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>
|
||||
|
||||
<div class="flex flex-wrap gap-2 ml-auto items-center">
|
||||
<n-button size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
||||
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
||||
<n-button v-if="viewMode === 'file'" class="mr-2" type="primary" secondary @click="exitFileMode">
|
||||
返回实时日志
|
||||
</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>
|
||||
|
||||
<n-config-provider :theme="darkTheme" :theme-overrides="{ Scrollbar: { width: '8px' } }">
|
||||
<n-virtual-list
|
||||
class="h-[calc(100vh-300px)] overflow-hidden bg-gray-900"
|
||||
:item-size="42"
|
||||
item-resizable
|
||||
:hoverable="false"
|
||||
:items="filteredLogs"
|
||||
:scrollbar-props="{ trigger: 'none' }">
|
||||
<template #default="{ item: { level, formatedLog } }: { item: LogRecord }">
|
||||
<div :class="['py-1 px-3 hover:bg-white/10 whitespace-pre-wrap mr-4', getLevelStyles(level)]">
|
||||
{{ formatedLog }}
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</n-config-provider>
|
||||
<div class="pt-1 flex">
|
||||
<n-button ghost class="ml-auto" size="small" type="error" @click="clearLogRecords">清空日志浏览器</n-button>
|
||||
<div class="pt-2 flex flex-wrap items-center">
|
||||
<n-checkbox v-model:checked="store.config.enable_file_logger">输出文件日志</n-checkbox>
|
||||
<n-button class="ml-2" size="small" @click="showLogsDirInFileManager">打开日志目录</n-button>
|
||||
<n-tag class="ml-1" size="small" :bordered="false">
|
||||
{{ formatedLogsDirSize }}
|
||||
</n-tag>
|
||||
|
||||
<n-button
|
||||
v-if="viewMode === 'live'"
|
||||
ghost
|
||||
class="ml-auto"
|
||||
size="small"
|
||||
type="error"
|
||||
@click="clearLiveLogRecords">
|
||||
清空实时日志
|
||||
</n-button>
|
||||
</div>
|
||||
</n-dialog>
|
||||
</n-modal>
|
||||
|
||||
Reference in New Issue
Block a user