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

View File

@@ -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
View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)))
}

View File

@@ -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 };
}
}
}

View File

@@ -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 }) => {
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') {
if (logRecord.level === 'ERROR') {
notification.error({
title: fields['err_title'] as string,
description: fields['message'] as string,
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-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>
</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 }}
<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>
</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="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-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>
</n-dialog>
</n-modal>