feat: 优化日志Dialog,支持查看实时日志和文件日志

This commit is contained in:
lanyeeee
2026-03-07 06:06:56 +08:00
parent 15cdb30763
commit e757c6b64e
7 changed files with 504 additions and 134 deletions
+1
View File
@@ -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"
+27
View File
@@ -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)
+39
View File
@@ -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)
}
+3 -3
View File
@@ -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
View File
@@ -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)))
} }
+8
View File
@@ -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 };
}
} }
} }
+411 -118
View File
@@ -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(() => {
events.logEvent
.listen(({ payload: logEvent }) => {
const logMetadata: LogMetadata = JSON.parse(logEvent.jsonRaw) 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)
const { level, fields } = logMetadata
if (level === 'ERROR') {
notification.error({ notification.error({
title: fields['err_title'] as string, title: (logRecord.fields['err_title'] as string) || 'Error',
description: fields['message'] as string, description: (logRecord.fields['message'] as string) || 'Unknown Error',
duration: 0, 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>
</n-input-group> </span>
<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>
</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> </div>
</template> </template>
</n-virtual-list>
</n-config-provider> <div class="mb-2 flex flex-wrap">
<div class="pt-1 flex"> <n-input-group class="flex-1 mr-4">
<n-button ghost class="ml-auto" size="small" type="error" @click="clearLogRecords">清空日志浏览器</n-button> <n-input v-model:value="filterText" placeholder="关键词过滤..." clearable />
<n-select v-model:value="selectedLevel" :options="logLevelOptions" style="width: 120px" />
</n-input-group>
<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>
<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> </div>
</n-dialog> </n-dialog>
</n-modal> </n-modal>