refactor: 日志查看功能&优化日志功能
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
stopBackend,
|
||||
} from './services/pythonService'
|
||||
import { setMainWindow as setGitMainWindow, downloadGit, cloneBackend } from './services/gitService'
|
||||
import { setupLogger, log, getLogPath, cleanOldLogs } from './services/logService'
|
||||
import { setupLogger, log, getLogPath, getLogFiles, cleanOldLogs } from './services/logService'
|
||||
|
||||
// 检查是否以管理员权限运行
|
||||
function isRunningAsAdmin(): boolean {
|
||||
@@ -256,9 +256,27 @@ ipcMain.handle('get-log-path', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-logs', async (event, lines?: number) => {
|
||||
ipcMain.handle('get-log-files', async () => {
|
||||
try {
|
||||
const logFilePath = getLogPath()
|
||||
return getLogFiles()
|
||||
} catch (error) {
|
||||
log.error('获取日志文件列表失败:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-logs', async (event, lines?: number, fileName?: string) => {
|
||||
try {
|
||||
let logFilePath: string
|
||||
|
||||
if (fileName) {
|
||||
// 如果指定了文件名,使用指定的文件
|
||||
const appRoot = getAppRoot()
|
||||
logFilePath = path.join(appRoot, 'logs', fileName)
|
||||
} else {
|
||||
// 否则使用当前日志文件
|
||||
logFilePath = getLogPath()
|
||||
}
|
||||
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
return ''
|
||||
@@ -278,13 +296,22 @@ ipcMain.handle('get-logs', async (event, lines?: number) => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('clear-logs', async () => {
|
||||
ipcMain.handle('clear-logs', async (event, fileName?: string) => {
|
||||
try {
|
||||
const logFilePath = getLogPath()
|
||||
let logFilePath: string
|
||||
|
||||
if (fileName) {
|
||||
// 如果指定了文件名,清空指定的文件
|
||||
const appRoot = getAppRoot()
|
||||
logFilePath = path.join(appRoot, 'logs', fileName)
|
||||
} else {
|
||||
// 否则清空当前日志文件
|
||||
logFilePath = getLogPath()
|
||||
}
|
||||
|
||||
if (fs.existsSync(logFilePath)) {
|
||||
fs.writeFileSync(logFilePath, '', 'utf8')
|
||||
log.info('日志文件已清空')
|
||||
log.info(`日志文件已清空: ${fileName || '当前文件'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('清空日志文件失败:', error)
|
||||
|
||||
@@ -32,8 +32,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 日志文件操作
|
||||
getLogPath: () => ipcRenderer.invoke('get-log-path'),
|
||||
getLogs: (lines?: number) => ipcRenderer.invoke('get-logs', lines),
|
||||
clearLogs: () => ipcRenderer.invoke('clear-logs'),
|
||||
getLogFiles: () => ipcRenderer.invoke('get-log-files'),
|
||||
getLogs: (lines?: number, fileName?: string) => ipcRenderer.invoke('get-logs', lines, fileName),
|
||||
clearLogs: (fileName?: string) => ipcRenderer.invoke('clear-logs', fileName),
|
||||
cleanOldLogs: (daysToKeep?: number) => ipcRenderer.invoke('clean-old-logs', daysToKeep),
|
||||
|
||||
// 保留原有方法以兼容现有代码
|
||||
|
||||
@@ -15,6 +15,15 @@ function getLogDirectory(): string {
|
||||
return path.join(appRoot, 'logs')
|
||||
}
|
||||
|
||||
// 获取当前日期的日志文件名 - 使用ISO 8601格式
|
||||
function getTodayLogFileName(): string {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `frontendlog-${year}-${month}-${day}.log`
|
||||
}
|
||||
|
||||
// 配置日志系统
|
||||
export function setupLogger() {
|
||||
// 设置日志文件路径到软件安装目录
|
||||
@@ -30,28 +39,21 @@ export function setupLogger() {
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||
|
||||
// 设置主进程日志文件路径和名称
|
||||
log.transports.file.resolvePathFn = () => path.join(logPath, 'app.log')
|
||||
// 设置主进程日志文件路径和名称 - 按日期分文件
|
||||
log.transports.file.resolvePathFn = () => {
|
||||
const fileName = getTodayLogFileName()
|
||||
return path.join(logPath, fileName)
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
log.transports.file.level = 'debug'
|
||||
log.transports.console.level = 'debug'
|
||||
|
||||
// 设置文件大小限制 (10MB)
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024
|
||||
// 设置文件大小限制 (50MB,因为按日期分文件,可以设置更大)
|
||||
log.transports.file.maxSize = 50 * 1024 * 1024
|
||||
|
||||
// 保留最近的5个日志文件
|
||||
log.transports.file.archiveLog = (file: any) => {
|
||||
const filePath = file.toString()
|
||||
const info = path.parse(filePath)
|
||||
|
||||
try {
|
||||
return path.join(info.dir, `${info.name}.old${info.ext}`)
|
||||
} catch (e) {
|
||||
console.warn('Could not archive log file', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
// 禁用自动归档,因为我们按日期分文件
|
||||
log.transports.file.archiveLog = null
|
||||
|
||||
// 捕获未处理的异常和Promise拒绝
|
||||
log.catchErrors({
|
||||
@@ -98,7 +100,7 @@ export function setupLogger() {
|
||||
}
|
||||
|
||||
log.info('日志系统初始化完成')
|
||||
log.info(`日志文件路径: ${path.join(logPath, 'main.log')}`)
|
||||
log.info(`日志文件路径: ${path.join(logPath, getTodayLogFileName())}`)
|
||||
|
||||
return log
|
||||
}
|
||||
@@ -106,9 +108,25 @@ export function setupLogger() {
|
||||
// 导出日志实例和工具函数
|
||||
export { log, stripAnsiColors }
|
||||
|
||||
// 获取日志文件路径
|
||||
// 获取当前日志文件路径
|
||||
export function getLogPath(): string {
|
||||
return path.join(getLogDirectory(), 'main.log')
|
||||
return path.join(getLogDirectory(), getTodayLogFileName())
|
||||
}
|
||||
|
||||
// 获取所有日志文件列表
|
||||
export function getLogFiles(): string[] {
|
||||
const fs = require('fs')
|
||||
const logDir = getLogDirectory()
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(logDir)
|
||||
return files
|
||||
.filter((file: string) => file.match(/^frontendlog-\d{4}-\d{2}-\d{2}\.log$/))
|
||||
.sort()
|
||||
.reverse() // 最新的在前面
|
||||
}
|
||||
|
||||
// 清理旧日志文件
|
||||
@@ -121,19 +139,27 @@ export function cleanOldLogs(daysToKeep: number = 7) {
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(logDir)
|
||||
const now = Date.now()
|
||||
const maxAge = daysToKeep * 24 * 60 * 60 * 1000 // 转换为毫秒
|
||||
const now = new Date()
|
||||
const cutoffDate = new Date(now.getTime() - daysToKeep * 24 * 60 * 60 * 1000)
|
||||
|
||||
// 格式化截止日期为YYYY-MM-DD
|
||||
const cutoffDateStr = cutoffDate.getFullYear() + '-' +
|
||||
String(cutoffDate.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(cutoffDate.getDate()).padStart(2, '0')
|
||||
|
||||
files.forEach((file: string) => {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = fs.statSync(filePath)
|
||||
|
||||
if (now - stats.mtime.getTime() > maxAge) {
|
||||
try {
|
||||
fs.unlinkSync(filePath)
|
||||
log.info(`已删除旧日志文件: ${file}`)
|
||||
} catch (error) {
|
||||
log.error(`删除旧日志文件失败: ${file}`, error)
|
||||
// 匹配日志文件名格式 frontendlog-YYYY-MM-DD.log
|
||||
const match = file.match(/^frontendlog-(\d{4}-\d{2}-\d{2})\.log$/)
|
||||
if (match) {
|
||||
const fileDateStr = match[1]
|
||||
if (fileDateStr < cutoffDateStr) {
|
||||
const filePath = path.join(logDir, file)
|
||||
try {
|
||||
fs.unlinkSync(filePath)
|
||||
log.info(`已删除旧日志文件: ${file}`)
|
||||
} catch (error) {
|
||||
log.error(`删除旧日志文件失败: ${file}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,94 +1,218 @@
|
||||
<template>
|
||||
<div class="log-viewer">
|
||||
<div class="log-controls">
|
||||
<a-space wrap>
|
||||
<a-button @click="refreshLogs" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新日志
|
||||
</a-button>
|
||||
<!-- 工具栏 -->
|
||||
<a-card size="small" class="toolbar-card">
|
||||
<a-row :gutter="[12, 12]" align="middle" justify="space-between" class="toolbar-grid">
|
||||
<!-- 左侧:刷新 + 选择器 -->
|
||||
<a-col :xs="24" :md="14">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button @click="refreshLogs" :loading="loading" type="primary">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新日志
|
||||
</a-button>
|
||||
|
||||
<a-select v-model:value="logLines" @change="refreshLogs" style="width: 120px">
|
||||
<a-select-option :value="100">最近100行</a-select-option>
|
||||
<a-select-option :value="500">最近500行</a-select-option>
|
||||
<a-select-option :value="1000">最近1000行</a-select-option>
|
||||
<a-select-option :value="0">全部日志</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="selectedLogFile" @change="onLogFileChange" style="width: 220px"
|
||||
placeholder="选择日志文件">
|
||||
<a-select-option value="">今日日志</a-select-option>
|
||||
<a-select-option v-for="file in logFiles" :key="file" :value="file">
|
||||
{{ formatLogFileName(file) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button @click="clearLogs" :loading="clearing" type="primary" danger>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
清空日志
|
||||
</a-button>
|
||||
<a-select v-model:value="logLines" @change="refreshLogs" style="width: 140px"
|
||||
placeholder="显示行数">
|
||||
<a-select-option :value="100">最近100行</a-select-option>
|
||||
<a-select-option :value="500">最近500行</a-select-option>
|
||||
<a-select-option :value="1000">最近1000行</a-select-option>
|
||||
<a-select-option :value="0">显示全部</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</a-col>
|
||||
|
||||
<a-button @click="cleanOldLogs" :loading="cleaning">
|
||||
<template #icon>
|
||||
<ClearOutlined />
|
||||
</template>
|
||||
清理旧日志
|
||||
</a-button>
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<a-col :xs="24" :md="10">
|
||||
<div class="toolbar-actions">
|
||||
<a-space :size="8" wrap>
|
||||
|
||||
<a-button @click="openLogDirectory">
|
||||
<template #icon>
|
||||
<FolderOpenOutlined />
|
||||
</template>
|
||||
打开日志目录
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="log-info">
|
||||
<a-space>
|
||||
<span>日志文件: {{ logPath }}</span>
|
||||
<span>总行数: {{ totalLines }}</span>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-popconfirm
|
||||
:title="`确定要清空${selectedLogFile ? formatLogFileName(selectedLogFile) : '今日日志'}吗?`"
|
||||
ok-text="确定" cancel-text="取消" @confirm="clearLogs">
|
||||
<a-button :loading="clearing" danger>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
清空当前日志
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
|
||||
<div class="log-content">
|
||||
<a-textarea v-model:value="logs" :rows="25" readonly class="log-textarea" placeholder="暂无日志内容" />
|
||||
</div>
|
||||
<a-button @click="cleanOldLogs" :loading="cleaning">
|
||||
<template #icon>
|
||||
<ClearOutlined />
|
||||
</template>
|
||||
清理7日前的旧日志
|
||||
</a-button>
|
||||
|
||||
<a-button @click="openLogDirectory">
|
||||
<template #icon>
|
||||
<FolderOpenOutlined />
|
||||
</template>
|
||||
打开日志所在目录
|
||||
</a-button>
|
||||
|
||||
<a-button @click="exportLogs">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出日志(txt格式)
|
||||
</a-button>
|
||||
<!-- <a-button @click="scrollToBottom" :disabled="!logs">-->
|
||||
<!-- <template #icon><DownOutlined /></template>-->
|
||||
<!-- 跳转底部-->
|
||||
<!-- </a-button>-->
|
||||
</a-space>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 日志内容 -->
|
||||
<a-card class="log-content-card">
|
||||
<template #title>
|
||||
<span>日志内容</span>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<div class="log-content" :class="{ 'word-wrap': wordWrap }">
|
||||
<a-spin :spinning="loading" tip="加载日志中..." class="log-spin">
|
||||
<div ref="logContainer" class="log-container" v-if="displayLogs">
|
||||
<pre class="log-text" v-html="displayContent"></pre>
|
||||
</div>
|
||||
<a-empty v-else description="暂无日志内容" :image="Empty.PRESENTED_IMAGE_SIMPLE" class="log-empty" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { message, Empty } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
ClearOutlined,
|
||||
FolderOpenOutlined
|
||||
FolderOpenOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { logger } from '@/utils/logger'
|
||||
|
||||
// 响应式数据
|
||||
const logs = ref('')
|
||||
const logPath = ref('')
|
||||
const logFiles = ref<string[]>([])
|
||||
const selectedLogFile = ref('')
|
||||
const logLines = ref(500)
|
||||
const totalLines = ref(0)
|
||||
const loading = ref(false)
|
||||
const clearing = ref(false)
|
||||
const cleaning = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const wordWrap = ref(true)
|
||||
const logContainer = ref<HTMLElement>()
|
||||
|
||||
// 自动刷新定时器
|
||||
let autoRefreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 计算属性
|
||||
|
||||
const displayLogs = computed(() => {
|
||||
const hasContent = logs.value && logs.value.trim().length > 0
|
||||
console.log('displayLogs computed:', {
|
||||
hasLogs: !!logs.value,
|
||||
logsLength: logs.value?.length || 0,
|
||||
trimmedLength: logs.value?.trim().length || 0,
|
||||
hasContent,
|
||||
firstChars: logs.value?.substring(0, 100) || 'empty',
|
||||
})
|
||||
return hasContent
|
||||
})
|
||||
|
||||
const displayContent = computed(() => {
|
||||
if (!logs.value) return ''
|
||||
// 转义HTML特殊字符
|
||||
return logs.value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
})
|
||||
|
||||
// 格式化日志文件名显示
|
||||
const formatLogFileName = (fileName: string) => {
|
||||
const match = fileName.match(/^frontendlog-(\d{4}-\d{2}-\d{2})\.log$/)
|
||||
if (match) {
|
||||
const [, dateStr] = match
|
||||
// 转换为更友好的中文显示
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}
|
||||
return date.toLocaleDateString('zh-CN', options)
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
// 获取日志文件列表
|
||||
const getLogFiles = async () => {
|
||||
try {
|
||||
const files = await logger.getLogFiles()
|
||||
logFiles.value = files
|
||||
} catch (error) {
|
||||
logger.error('获取日志文件列表失败:', error)
|
||||
logFiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 日志文件选择变化
|
||||
const onLogFileChange = () => {
|
||||
refreshLogs()
|
||||
}
|
||||
|
||||
// 刷新日志
|
||||
const refreshLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const logContent = await logger.getLogs(logLines.value || undefined)
|
||||
logs.value = logContent
|
||||
totalLines.value = logContent.split('\n').filter(line => line.trim()).length
|
||||
console.log('开始获取日志,文件:', selectedLogFile.value, '行数限制:', logLines.value)
|
||||
const logContent = await logger.getLogs(
|
||||
logLines.value || undefined,
|
||||
selectedLogFile.value || undefined
|
||||
)
|
||||
console.log('获取到的日志内容:', {
|
||||
type: typeof logContent,
|
||||
length: logContent?.length || 0,
|
||||
isNull: logContent === null,
|
||||
isUndefined: logContent === undefined,
|
||||
isEmpty: logContent === '',
|
||||
preview: logContent?.substring(0, 200) || 'no content',
|
||||
})
|
||||
|
||||
logs.value = logContent || ''
|
||||
|
||||
// 日志内容已更新
|
||||
|
||||
// 自动滚动到底部
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector('.log-textarea textarea') as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight
|
||||
}
|
||||
}, 100)
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
message.error('获取日志失败: ' + error)
|
||||
console.error('获取日志失败:', error)
|
||||
message.error(`获取日志失败: ${error}`)
|
||||
logger.error('获取日志失败:', error)
|
||||
logs.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -98,12 +222,12 @@ const refreshLogs = async () => {
|
||||
const clearLogs = async () => {
|
||||
clearing.value = true
|
||||
try {
|
||||
await logger.clearLogs()
|
||||
await logger.clearLogs(selectedLogFile.value || undefined)
|
||||
logs.value = ''
|
||||
totalLines.value = 0
|
||||
message.success('日志已清空')
|
||||
const fileName = selectedLogFile.value ? formatLogFileName(selectedLogFile.value) : '今日日志'
|
||||
message.success(`${fileName}已清空`)
|
||||
} catch (error) {
|
||||
message.error('清空日志失败: ' + error)
|
||||
message.error(`清空日志失败: ${error}`)
|
||||
logger.error('清空日志失败:', error)
|
||||
} finally {
|
||||
clearing.value = false
|
||||
@@ -116,8 +240,11 @@ const cleanOldLogs = async () => {
|
||||
try {
|
||||
await logger.cleanOldLogs(7)
|
||||
message.success('已清理7天前的旧日志文件')
|
||||
// 清理后刷新日志文件列表和当前日志
|
||||
await getLogFiles()
|
||||
await refreshLogs()
|
||||
} catch (error) {
|
||||
message.error('清理旧日志失败: ' + error)
|
||||
message.error(`清理旧日志失败: ${error}`)
|
||||
logger.error('清理旧日志失败:', error)
|
||||
} finally {
|
||||
cleaning.value = false
|
||||
@@ -132,58 +259,329 @@ const openLogDirectory = async () => {
|
||||
const logDir = path.substring(0, path.lastIndexOf('\\') || path.lastIndexOf('/'))
|
||||
|
||||
if (window.electronAPI?.openUrl) {
|
||||
await window.electronAPI.openUrl(`file://${logDir}`)
|
||||
const result = await window.electronAPI.openUrl(`file://${logDir}`)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '打开目录失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error('Electron API 不可用')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('打开日志目录失败: ' + error)
|
||||
message.error(`打开日志目录失败: ${error}`)
|
||||
logger.error('打开日志目录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出日志
|
||||
const exportLogs = async () => {
|
||||
try {
|
||||
if (!logs.value) {
|
||||
message.warning('没有日志内容可导出')
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([logs.value], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
|
||||
// 使用当前选择的日志文件名或默认名称
|
||||
let fileName = 'logs'
|
||||
if (selectedLogFile.value) {
|
||||
fileName = selectedLogFile.value.replace('.log', '')
|
||||
} else {
|
||||
fileName = `logs_${new Date().toISOString().slice(0, 10)}`
|
||||
}
|
||||
a.download = `${fileName}.txt`
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
message.success('日志导出成功')
|
||||
} catch (error) {
|
||||
message.error(`导出日志失败: ${error}`)
|
||||
logger.error('导出日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
if (logContainer.value) {
|
||||
// 使用 nextTick 确保DOM已更新
|
||||
nextTick(() => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
// 添加平滑滚动效果
|
||||
logContainer.value.scrollTo({
|
||||
top: logContainer.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新
|
||||
const startAutoRefresh = () => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
refreshLogs()
|
||||
}, 2000) // 每2秒刷新一次
|
||||
}
|
||||
|
||||
// 获取日志文件路径
|
||||
const getLogPath = async () => {
|
||||
try {
|
||||
logPath.value = await logger.getLogPath()
|
||||
} catch (error) {
|
||||
logger.error('获取日志路径失败:', error)
|
||||
logPath.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getLogPath()
|
||||
refreshLogs()
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await getLogPath()
|
||||
await getLogFiles()
|
||||
await refreshLogs()
|
||||
// 启动自动刷新
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
padding: 16px;
|
||||
height: 85vh;
|
||||
/* 减少整体高度 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
/* 减少间距 */
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
margin-bottom: 16px;
|
||||
.toolbar-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 响应式处理 */
|
||||
@media (max-width: 1400px) {
|
||||
.toolbar-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.log-content-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.log-content-card :deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
/* 减少内边距 */
|
||||
}
|
||||
|
||||
.log-content {
|
||||
border: 1px solid #d9d9d9;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-bg-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-textarea :deep(.ant-input) {
|
||||
.log-spin {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: var(--ant-color-bg-elevated);
|
||||
border-radius: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border: none;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
color: var(--ant-color-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-textarea :deep(.ant-input:focus) {
|
||||
box-shadow: none;
|
||||
.word-wrap .log-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* 滚动条样式 - 适配深色模式 */
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: var(--ant-color-bg-container);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
:deep(.ant-empty) {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
:deep(.ant-empty-description) {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
:deep(.ant-spin-container) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-spin-nested-loading) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 深色模式特定样式 */
|
||||
[data-theme='dark'] .log-content {
|
||||
border-color: #434343;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-container {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-text {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-container::-webkit-scrollbar-track {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-container::-webkit-scrollbar-thumb {
|
||||
background: #434343;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #595959;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.log-viewer {
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 日志级别颜色 - 适配深色模式 */
|
||||
.log-text {
|
||||
/* ERROR 级别 - 红色 */
|
||||
--log-error-color: #ff4d4f;
|
||||
--log-error-bg: rgba(255, 77, 79, 0.1);
|
||||
|
||||
/* WARN 级别 - 橙色 */
|
||||
--log-warn-color: #fa8c16;
|
||||
--log-warn-bg: rgba(250, 140, 22, 0.1);
|
||||
|
||||
/* INFO 级别 - 蓝色 */
|
||||
--log-info-color: #1890ff;
|
||||
--log-info-bg: rgba(24, 144, 255, 0.1);
|
||||
|
||||
/* DEBUG 级别 - 绿色 */
|
||||
--log-debug-color: #52c41a;
|
||||
--log-debug-bg: rgba(82, 196, 26, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-text {
|
||||
--log-error-color: #ff7875;
|
||||
--log-error-bg: rgba(255, 120, 117, 0.15);
|
||||
|
||||
--log-warn-color: #ffa940;
|
||||
--log-warn-bg: rgba(255, 169, 64, 0.15);
|
||||
|
||||
--log-info-color: #40a9ff;
|
||||
--log-info-bg: rgba(64, 169, 255, 0.15);
|
||||
|
||||
--log-debug-color: #73d13d;
|
||||
--log-debug-bg: rgba(115, 209, 61, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -76,6 +76,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/Settings.vue'),
|
||||
meta: { title: '设置' },
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
meta: { title: '日志查看' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
5
frontend/src/types/electron.d.ts
vendored
5
frontend/src/types/electron.d.ts
vendored
@@ -25,8 +25,9 @@ export interface ElectronAPI {
|
||||
|
||||
// 日志文件操作
|
||||
getLogPath: () => Promise<string>
|
||||
getLogs: (lines?: number) => Promise<string>
|
||||
clearLogs: () => Promise<void>
|
||||
getLogFiles: () => Promise<string[]>
|
||||
getLogs: (lines?: number, fileName?: string) => Promise<string>
|
||||
clearLogs: (fileName?: string) => Promise<void>
|
||||
cleanOldLogs: (daysToKeep?: number) => Promise<void>
|
||||
|
||||
// 保留原有方法以兼容现有代码
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 渲染进程日志工具
|
||||
interface ElectronAPI {
|
||||
getLogPath: () => Promise<string>
|
||||
getLogs: (lines?: number) => Promise<string>
|
||||
clearLogs: () => Promise<void>
|
||||
getLogFiles: () => Promise<string[]>
|
||||
getLogs: (lines?: number, fileName?: string) => Promise<string>
|
||||
clearLogs: (fileName?: string) => Promise<void>
|
||||
cleanOldLogs: (daysToKeep?: number) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -45,19 +46,27 @@ class Logger {
|
||||
throw new Error('Electron API not available')
|
||||
}
|
||||
|
||||
// 获取日志内容
|
||||
async getLogs(lines?: number): Promise<string> {
|
||||
// 获取日志文件列表
|
||||
async getLogFiles(): Promise<string[]> {
|
||||
if (window.electronAPI) {
|
||||
return await window.electronAPI.getLogs(lines)
|
||||
return await window.electronAPI.getLogFiles()
|
||||
}
|
||||
throw new Error('Electron API not available')
|
||||
}
|
||||
|
||||
// 获取日志内容
|
||||
async getLogs(lines?: number, fileName?: string): Promise<string> {
|
||||
if (window.electronAPI) {
|
||||
return await window.electronAPI.getLogs(lines, fileName)
|
||||
}
|
||||
throw new Error('Electron API not available')
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
async clearLogs(): Promise<void> {
|
||||
async clearLogs(fileName?: string): Promise<void> {
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.clearLogs()
|
||||
console.info('日志已清空')
|
||||
await window.electronAPI.clearLogs(fileName)
|
||||
console.info(`日志已清空: ${fileName || '当前文件'}`)
|
||||
} else {
|
||||
throw new Error('Electron API not available')
|
||||
}
|
||||
|
||||
51
frontend/src/views/Logs.vue
Normal file
51
frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="logs-container">
|
||||
<div class="logs-header">
|
||||
<h1>日志查看</h1>
|
||||
<p class="logs-description">查看和管理应用程序日志</p>
|
||||
</div>
|
||||
|
||||
<LogViewer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import LogViewer from '@/components/LogViewer.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const textColor = computed(() =>
|
||||
isDark.value ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)'
|
||||
)
|
||||
const textSecondaryColor = computed(() =>
|
||||
isDark.value ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.65)'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logs-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: v-bind(textColor);
|
||||
}
|
||||
|
||||
.logs-description {
|
||||
margin: 0;
|
||||
color: v-bind(textSecondaryColor);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,6 +20,7 @@ import { useSettingsApi } from '../composables/useSettingsApi'
|
||||
import type { SelectValue } from 'ant-design-vue/es/select'
|
||||
import type { SettingsData } from '../types/settings'
|
||||
|
||||
const router = useRouter()
|
||||
const { themeMode, themeColor, themeColors, setThemeMode, setThemeColor, isDark } = useTheme()
|
||||
const { loading, getSettings, updateSettings } = useSettingsApi()
|
||||
|
||||
@@ -182,6 +184,10 @@ const handleThemeColorChange = (value: SelectValue) => {
|
||||
}
|
||||
}
|
||||
|
||||
const goToLogs = () => {
|
||||
router.push('/logs')
|
||||
}
|
||||
|
||||
const openDevTools = () => {
|
||||
if ((window as any).electronAPI) {
|
||||
;(window as any).electronAPI.openDevTools()
|
||||
@@ -679,6 +685,14 @@ onMounted(() => {
|
||||
<Tabs.TabPane key="advanced" tab="高级设置">
|
||||
<Card title="开发者选项" :bordered="false">
|
||||
<Space direction="vertical" size="middle" style="width: 100%">
|
||||
<div class="setting-item">
|
||||
<h4>日志管理</h4>
|
||||
<p class="setting-description">查看和管理应用程序日志文件</p>
|
||||
<Button type="primary" @click="goToLogs">查看日志</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="setting-item">
|
||||
<h4>开发者工具</h4>
|
||||
<p class="setting-description">打开浏览器开发者工具进行调试</p>
|
||||
|
||||
Reference in New Issue
Block a user