feat: 日志Dialog

This commit is contained in:
lanyeeee
2025-08-03 05:56:45 +08:00
parent 7b9fe63799
commit 44a118605f
10 changed files with 389 additions and 175 deletions
+14
View File
@@ -9,5 +9,19 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialog: typeof import('naive-ui')['NDialog']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NSelect: typeof import('naive-ui')['NSelect']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
}
}
+1
View File
@@ -10,6 +10,7 @@
"tauri": "tauri"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"naive-ui": "^2.42.0",
+13
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@phosphor-icons/vue':
specifier: ^2.2.1
version: 2.2.1(vue@3.5.17(typescript@5.6.3))
'@tauri-apps/api':
specifier: ^2
version: 2.6.0
@@ -493,6 +496,12 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@phosphor-icons/vue@2.2.1':
resolution: {integrity: sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==}
engines: {node: '>=14'}
peerDependencies:
vue: '>=3.2.39'
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2381,6 +2390,10 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
'@phosphor-icons/vue@2.2.1(vue@3.5.17(typescript@5.6.3))':
dependencies:
vue: 3.5.17(typescript@5.6.3)
'@polka/url@1.0.0-next.29': {}
'@quansync/fs@0.1.3':
+31
View File
@@ -1,5 +1,7 @@
use anyhow::Context;
use parking_lot::RwLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use crate::{
config::Config,
@@ -387,3 +389,32 @@ pub async fn search(app: AppHandle, params: SearchParams) -> CommandResult<Searc
};
Ok(search_result)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
pub fn get_logs_dir_size(app: AppHandle) -> CommandResult<u64> {
let logs_dir = logger::logs_dir(&app)
.context("获取日志目录失败")
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?;
let logs_dir_size = std::fs::read_dir(&logs_dir)
.context(format!("读取日志目录`{}`失败", logs_dir.display()))
.map_err(|err| CommandError::from("获取日志目录大小失败", err))?
.filter_map(Result::ok)
.filter_map(|entry| entry.metadata().ok())
.map(|metadata| metadata.len())
.sum::<u64>();
tracing::debug!("获取日志目录大小成功");
Ok(logs_dir_size)
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> {
app.opener()
.reveal_item_in_dir(path)
.context(format!("在文件管理器中打开`{path}`失败"))
.map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
Ok(())
}
+2
View File
@@ -58,6 +58,8 @@ pub fn run() {
restart_download_tasks,
restore_download_tasks,
search,
get_logs_dir_size,
show_path_in_file_manager,
])
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]);
+43 -174
View File
@@ -1,180 +1,49 @@
<script setup lang="tsx">
import { ref } from 'vue'
import { commands } from './bindings.ts'
<script setup lang="ts">
import AppContent from './AppContent.vue'
import { GlobalThemeOverrides } from 'naive-ui'
const greetMsg = ref('')
const name = ref('')
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
greetMsg.value = await commands.greet(name.value)
}
async function test() {
const result = await commands.generateQrcode()
console.log(result)
if (result.status === 'ok') {
const result2 = await commands.getQrcodeStatus(result.data.qrcode_key)
console.log(result2)
}
}
function TestComponent() {
return (
<div>
<h2>Test Component</h2>
<p>This is a test component.</p>
</div>
)
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0EA5E9',
primaryColorHover: '#36BFF5',
primaryColorPressed: '#027FC2',
primaryColorSuppl: '#36BFF5',
borderRadius: '4px',
borderRadiusSmall: '3px',
heightMedium: '32px',
},
Tabs: {
tabGapSmallLine: '10px',
tabPaddingSmallLine: '6px 8px',
},
Button: {
paddingSmall: '0 8px',
paddingMedium: '0 12px',
},
Radio: {
buttonColorActive: '#0EA5E9',
buttonTextColorActive: '#FFF',
},
Dropdown: {
borderRadius: '5px',
padding: '6px 2px',
optionColorHover: '#0EA5E9',
optionTextColorHover: '#FFF',
optionHeightMedium: '28px',
},
}
</script>
<template>
<main class="container">
<h1>Welcome to Tauri + Vue</h1>
<div class="row">
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and Vue logos to learn more.</p>
<form class="row" @submit.prevent="greet">
<input id="greet-input" v-model="name" placeholder="Enter a name..." />
<button type="submit">Greet</button>
</form>
<p class="text-red">{{ greetMsg }}</p>
<n-button @click="test">测试</n-button>
<TestComponent />
</main>
<n-config-provider :theme-overrides="themeOverrides">
<n-dialog-provider>
<n-modal-provider>
<n-notification-provider placement="bottom-right" :max="3">
<n-message-provider>
<AppContent />
</n-message-provider>
</n-notification-provider>
</n-modal-provider>
</n-dialog-provider>
</n-config-provider>
</template>
<style scoped>
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #249b73);
}
</style>
<style>
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="tsx">
import { onMounted, ref } from 'vue'
import { useStore } from './store.ts'
import LogDialog from './dialogs/LogDialog.vue'
import { PhClockCounterClockwise } from '@phosphor-icons/vue'
import { commands } from './bindings.ts'
const store = useStore()
const logDialogShowing = ref<boolean>(false)
onMounted(async () => {
// 屏蔽浏览器右键菜单
document.oncontextmenu = (event) => {
event.preventDefault()
}
store.config = await commands.getConfig()
})
</script>
<template>
<div class="h-screen flex flex-col">
<div v-if="store.config !== undefined" class="h-full w-full flex overflow-hidden select-none">
<div class="flex flex-col box-border p-1 border-r-solid border-r-1 border-r-[#DADADA] bg-[#F9F9F9] flex-shrink-0">
<n-tooltip placement="right" trigger="hover" :show-arrow="false">
日志
<template #trigger>
<n-button text class="py-1 px-2" @click="logDialogShowing = true">
<n-icon size="28">
<PhClockCounterClockwise />
</n-icon>
</n-button>
</template>
</n-tooltip>
</div>
</div>
<LogDialog v-model:showing="logDialogShowing" />
</div>
</template>
<style scoped>
:global(.n-notification-main__header) {
@apply break-words;
}
:global(.n-tabs-pane-wrapper) {
@apply h-full;
}
:global(.selection-area) {
@apply bg-[rgba(46,115,252,0.5)];
}
:deep(.n-badge-sup) {
@apply pointer-events-none;
}
</style>
+16
View File
@@ -161,6 +161,22 @@ async search(params: SearchParams) : Promise<Result<SearchResult, CommandError>>
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLogsDirSize() : Promise<Result<number, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_logs_dir_size") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async showPathInFileManager(path: string) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("show_path_in_file_manager", { path }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="tsx">
import { LogEvent, LogLevel, events, commands } from '../bindings.ts'
import { useNotification } from 'naive-ui'
import { onMounted, ref, watch, computed } from 'vue'
import { appDataDir } from '@tauri-apps/api/path'
import { path } from '@tauri-apps/api'
import { useStore } from '../store.ts'
import { darkTheme } from 'naive-ui'
type LogRecord = LogEvent & { id: number; formatedLog: string }
const store = useStore()
const notification = useNotification()
const showing = defineModel<boolean>('showing', { required: true })
let nextLogRecordId = 1
const logRecords = ref<LogRecord[]>([])
const searchText = 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
let unitIndex = 0
while (size >= 1024 && unitIndex < 2) {
size /= 1024
unitIndex++
}
// 保留两位小数
return `${size.toFixed(2)} ${units[unitIndex]}`
})
const filteredLogs = computed<LogRecord[]>(() => {
return logRecords.value.filter(({ level, formatedLog }) => {
// 定义日志等级的优先级顺序
const logLevelPriority = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
}
// 首先按日志等级筛选
if (logLevelPriority[level] < logLevelPriority[selectedLevel.value]) {
return false
}
// 然后按搜索文本筛选
if (searchText.value === '') {
return true
}
return formatedLog.toLowerCase().includes(searchText.value.toLowerCase())
})
})
watch(showing, async () => {
if (showing.value) {
const result = await commands.getLogsDirSize()
if (result.status === 'error') {
console.error(result.error)
return
}
logsDirSize.value = result.data
}
})
onMounted(async () => {
await events.logEvent.listen(async ({ payload: logEvent }) => {
const logRecord: LogRecord = {
...logEvent,
id: nextLogRecordId++,
formatedLog: formatLogEvent(logEvent),
}
logRecords.value.push(logRecord)
const { level, fields } = logEvent
if (level === 'ERROR') {
notification.error({
title: fields['err_title'] as string,
description: fields['message'] as string,
duration: 0,
})
}
})
})
function formatLogEvent(logEvent: LogEvent): string {
const { timestamp, level, fields, target, filename, line_number } = logEvent
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 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'
}
}
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
}
async function showLogsDirInFileManager() {
const logsDir = await path.join(await appDataDir(), '日志')
const result = await commands.showPathInFileManager(logsDir)
if (result.status === 'error') {
console.error(result.error)
}
}
</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 }}
</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>
</n-dialog>
</n-modal>
</template>
+5 -1
View File
@@ -1,5 +1,9 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { Config } from './bindings.ts'
export const useStore = defineStore('store', () => {
return {}
const config = ref<Config>()
return { config }
})