添加开发者日志功能
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s

This commit is contained in:
孙诚
2025-12-29 16:45:51 +08:00
parent cbbb0c10cb
commit a13e1fe9e8
6 changed files with 739 additions and 2 deletions

34
Web/src/api/log.js Normal file
View File

@@ -0,0 +1,34 @@
import request from './request'
/**
* 日志相关 API
*/
/**
* 获取日志列表(分页)
* @param {Object} params - 查询参数
* @param {number} [params.pageIndex=1] - 页码
* @param {number} [params.pageSize=50] - 每页条数
* @param {string} [params.searchKeyword] - 搜索关键词
* @param {string} [params.logLevel] - 日志级别
* @param {string} [params.date] - 日期 (yyyyMMdd)
* @returns {Promise<{success: boolean, data: Array, total: number}>}
*/
export const getLogList = (params = {}) => {
return request({
url: '/Log/GetList',
method: 'get',
params
})
}
/**
* 获取可用的日志日期列表
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getAvailableDates = () => {
return request({
url: '/Log/GetAvailableDates',
method: 'get'
})
}

View File

@@ -81,6 +81,12 @@ const router = createRouter({
name: 'periodic-record',
component: () => import('../views/PeriodicRecord.vue'),
meta: { requiresAuth: true },
},
{
path: '/log',
name: 'log',
component: () => import('../views/LogView.vue'),
meta: { requiresAuth: true },
}
],
})

456
Web/src/views/LogView.vue Normal file
View File

@@ -0,0 +1,456 @@
<template>
<div class="page-container-flex log-view">
<van-nav-bar
title="查看日志"
left-text="返回"
left-arrow
@click-left="handleBack"
placeholder
/>
<div class="scroll-content">
<!-- 搜索和筛选 -->
<div class="filter-section">
<van-search
v-model="searchKeyword"
placeholder="输入关键词筛选日志"
@search="handleSearch"
@clear="handleClear"
/>
<div class="filter-row">
<van-dropdown-menu>
<van-dropdown-item v-model="selectedLevel" :options="levelOptions" @change="handleSearch" />
<van-dropdown-item v-model="selectedDate" :options="dateOptions" @change="handleSearch" />
</van-dropdown-menu>
</div>
</div>
<!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 加载提示 -->
<van-loading v-if="loading && !logList.length" vertical style="padding: 50px 0">
加载中...
</van-loading>
<!-- 日志列表 -->
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
class="log-list"
>
<div
v-for="(log, index) in logList"
:key="index"
class="log-item"
:class="getLevelClass(log.level)"
>
<div class="log-header">
<span class="log-level">{{ log.level }}</span>
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
</div>
<div class="log-message">{{ log.message }}</div>
</div>
<!-- 空状态 -->
<van-empty
v-if="!loading && !logList.length"
description="暂无日志"
image="search"
/>
</van-list>
<!-- 底部安全距离 -->
<div style="height: 20px"></div>
</van-pull-refresh>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { getLogList, getAvailableDates } from '@/api/log'
const router = useRouter()
// 数据状态
const logList = ref([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const fetching = ref(false)
// 分页参数
const pageIndex = ref(1)
const pageSize = ref(100)
const total = ref(0)
// 筛选参数
const searchKeyword = ref('')
const selectedLevel = ref('')
const selectedDate = ref('')
// 日志级别选项
const levelOptions = ref([
{ text: '全部级别', value: '' },
{ text: 'VRB', value: 'VRB' },
{ text: 'DBG', value: 'DBG' },
{ text: 'INF', value: 'INF' },
{ text: 'WRN', value: 'WRN' },
{ text: 'ERR', value: 'ERR' },
{ text: 'FTL', value: 'FTL' }
])
// 日期选项
const dateOptions = ref([
{ text: '全部日期', value: '' }
])
/**
* 返回上一页
*/
const handleBack = () => {
router.back()
}
/**
* 获取日志级别对应的样式类
*/
const getLevelClass = (level) => {
const levelMap = {
'ERR': 'level-error',
'FTL': 'level-fatal',
'WRN': 'level-warning',
'INF': 'level-info',
'DBG': 'level-debug',
'VRB': 'level-verbose'
}
return levelMap[level] || 'level-default'
}
/**
* 格式化时间显示
*/
const formatTime = (timestamp) => {
// 提取时间部分,去掉时区
const match = timestamp.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})/)
return match ? match[1] : timestamp
}
/**
* 加载日志数据
*/
const loadLogs = async (reset = false) => {
if (fetching.value) return
fetching.value = true
try {
if (reset) {
pageIndex.value = 1
logList.value = []
finished.value = false
}
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize.value
}
if (searchKeyword.value) {
params.searchKeyword = searchKeyword.value
}
if (selectedLevel.value) {
params.logLevel = selectedLevel.value
}
if (selectedDate.value) {
params.date = selectedDate.value
}
const response = await getLogList(params)
if (response.success) {
const newLogs = response.data || []
if (reset) {
logList.value = newLogs
} else {
logList.value = [...logList.value, ...newLogs]
}
total.value = response.total
// 判断是否还有更多数据
if (logList.value.length >= total.value || newLogs.length < pageSize.value) {
finished.value = true
} else {
finished.value = false
}
} else {
showToast(response.message || '获取日志失败')
finished.value = true
}
} catch (error) {
console.error('加载日志失败:', error)
showToast('加载日志失败')
finished.value = true
} finally {
fetching.value = false
loading.value = false
refreshing.value = false
}
}
/**
* 下拉刷新
*/
const onRefresh = async () => {
await loadLogs(true)
}
/**
* 加载更多
*/
const onLoad = async () => {
if (finished.value || fetching.value) return
// 如果是第一次加载
if (pageIndex.value === 1 && logList.value.length === 0) {
await loadLogs(false)
} else {
// 后续分页加载
pageIndex.value++
await loadLogs(false)
}
}
/**
* 搜索处理
*/
const handleSearch = () => {
loadLogs(true)
}
/**
* 清空搜索
*/
const handleClear = () => {
searchKeyword.value = ''
handleSearch()
}
/**
* 加载可用日期列表
*/
const loadAvailableDates = async () => {
try {
const response = await getAvailableDates()
if (response.success && response.data) {
const dates = response.data.map(date => ({
text: formatDate(date),
value: date
}))
dateOptions.value = [
{ text: '全部日期', value: '' },
...dates
]
}
} catch (error) {
console.error('加载日期列表失败:', error)
}
}
/**
* 格式化日期显示
*/
const formatDate = (dateStr) => {
// dateStr 格式: 20251229
if (dateStr.length === 8) {
return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
}
return dateStr
}
// 组件挂载时加载数据
onMounted(() => {
loadAvailableDates()
// 不在这里调用 loadLogs让 van-list 的 @load 事件自动触发
})
</script>
<style scoped>
.log-view {
height: 100vh;
background-color: #f5f5f5;
}
@media (prefers-color-scheme: dark) {
.log-view {
background-color: #1a1a1a;
}
}
.filter-section {
background-color: #ffffff;
position: sticky;
top: 0;
z-index: 10;
}
@media (prefers-color-scheme: dark) {
.filter-section {
background-color: #2c2c2c;
}
}
.filter-row {
padding: 0;
}
.log-list {
padding: 4px 12px;
}
.log-item {
background-color: #ffffff;
border-left: 3px solid #1989fa;
margin-bottom: 4px;
padding: 6px 10px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition: all 0.2s;
}
.log-item:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.log-item {
background-color: #2c2c2c;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.log-item:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 11px;
}
.log-level {
display: inline-block;
padding: 1px 6px;
border-radius: 2px;
font-weight: bold;
color: #ffffff;
min-width: 36px;
text-align: center;
font-size: 10px;
}
.log-time {
color: #969799;
font-size: 10px;
}
@media (prefers-color-scheme: dark) {
.log-time {
color: #858585;
}
}
.log-message {
color: #323233;
line-height: 1.4;
word-break: break-word;
white-space: pre-wrap;
}
@media (prefers-color-scheme: dark) {
.log-message {
color: #e0e0e0;
}
}
/* 不同日志级别的颜色 */
.level-verbose .log-level {
background-color: #b0b0b0;
}
.level-debug .log-level {
background-color: #1989fa;
}
.level-info .log-level {
background-color: #07c160;
}
.level-warning .log-level {
background-color: #ff976a;
border-left-color: #ff976a;
}
.level-error .log-level {
background-color: #ee0a24;
}
.level-fatal .log-level {
background-color: #8b0000;
}
.level-default .log-level {
background-color: #646566;
}
.level-verbose {
border-left-color: #b0b0b0;
}
.level-debug {
border-left-color: #1989fa;
}
.level-info {
border-left-color: #07c160;
}
.level-warning {
border-left-color: #ff976a;
}
.level-error {
border-left-color: #ee0a24;
}
.level-fatal {
border-left-color: #8b0000;
}
.level-default {
border-left-color: #646566;
}
/* 优化下拉菜单样式 */
:deep(.van-dropdown-menu) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
:deep(.van-dropdown-menu) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
}
</style>

View File

@@ -23,6 +23,12 @@
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
<van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>开发者</p>
</div>
<van-cell-group inset>
<van-cell title="查看日志" is-link @click="handleLogView" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>账户</p>
@@ -145,6 +151,13 @@ const handleLogout = async () => {
showToast('已取消退出登录')
}
}
/**
* 处理查看日志
*/
const handleLogView = () => {
router.push({ name: 'log' })
}
</script>
<style scoped>

View File

@@ -0,0 +1,228 @@
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
public class LogController(ILogger<LogController> logger) : ControllerBase
{
/// <summary>
/// 获取日志列表(分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<LogEntry>> GetListAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? searchKeyword = null,
[FromQuery] string? logLevel = null,
[FromQuery] string? date = null
)
{
try
{
// 获取日志目录
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
return new PagedResponse<LogEntry>
{
Success = true,
Data = [],
Total = 0,
Message = "日志目录不存在"
};
}
// 确定要读取的日志文件
string logFilePath;
if (!string.IsNullOrEmpty(date))
{
logFilePath = Path.Combine(logDirectory, $"log-{date}.txt");
}
else
{
// 默认读取今天的日志
var today = DateTime.Now.ToString("yyyyMMdd");
logFilePath = Path.Combine(logDirectory, $"log-{today}.txt");
}
// 检查文件是否存在
if (!System.IO.File.Exists(logFilePath))
{
return new PagedResponse<LogEntry>
{
Success = true,
Data = [],
Total = 0,
Message = "日志文件不存在"
};
}
// 读取所有日志行(使用共享读取模式,允许其他进程写入)
var allLines = await ReadAllLinesAsync(logFilePath);
var logEntries = new List<LogEntry>();
foreach (var line in allLines)
{
if (string.IsNullOrWhiteSpace(line))
continue;
var logEntry = ParseLogLine(line);
if (logEntry != null)
{
// 应用筛选条件
if (!string.IsNullOrEmpty(searchKeyword) &&
!logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrEmpty(logLevel) &&
!logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase))
{
continue;
}
logEntries.Add(logEntry);
}
}
// 倒序排列(最新的在前面)
logEntries.Reverse();
var total = logEntries.Count;
var skip = (pageIndex - 1) * pageSize;
var pagedData = logEntries.Skip(skip).Take(pageSize).ToList();
return new PagedResponse<LogEntry>
{
Success = true,
Data = pagedData.ToArray(),
Total = total,
Message = "获取日志成功"
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取日志失败");
return new PagedResponse<LogEntry>
{
Success = false,
Data = [],
Total = 0,
Message = $"获取日志失败: {ex.Message}"
};
}
}
/// <summary>
/// 获取可用的日志日期列表
/// </summary>
[HttpGet]
public IActionResult GetAvailableDates()
{
try
{
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
return Ok(new { success = true, data = new List<string>() });
}
var logFiles = Directory.GetFiles(logDirectory, "log-*.txt");
var dates = logFiles
.Select(f => Path.GetFileNameWithoutExtension(f))
.Select(name => name.Replace("log-", ""))
.OrderByDescending(d => d)
.ToList();
return Ok(new { success = true, data = dates });
}
catch (Exception ex)
{
logger.LogError(ex, "获取日志日期列表失败");
return Ok(new { success = false, message = $"获取日志日期列表失败: {ex.Message}" });
}
}
/// <summary>
/// 解析单行日志
/// </summary>
private LogEntry? ParseLogLine(string line)
{
try
{
// 日志格式示例: [2025-12-29 16:38:45.730 +08:00] [INF] Message here
// 使用正则表达式解析
var match = System.Text.RegularExpressions.Regex.Match(
line,
@"^\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+[+-]\d{2}:\d{2})\]\s+\[(\w+)\]\s+(.*)$"
);
if (match.Success)
{
return new LogEntry
{
Timestamp = match.Groups[1].Value,
Level = match.Groups[2].Value,
Message = match.Groups[3].Value
};
}
// 如果不匹配标准格式,将整行作为消息
return new LogEntry
{
Timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"),
Level = "LOG",
Message = line
};
}
catch
{
return null;
}
}
/// <summary>
/// 读取文件所有行(支持共享读取)
/// </summary>
private async Task<string[]> ReadAllLinesAsync(string path)
{
var lines = new List<string>();
using (var fileStream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite))
using (var streamReader = new StreamReader(fileStream))
{
string? line;
while ((line = await streamReader.ReadLineAsync()) != null)
{
lines.Add(line);
}
}
return lines.ToArray();
}
}
/// <summary>
/// 日志条目
/// </summary>
public class LogEntry
{
/// <summary>
/// 时间戳
/// </summary>
public string Timestamp { get; set; } = string.Empty;
/// <summary>
/// 日志级别
/// </summary>
public string Level { get; set; } = string.Empty;
/// <summary>
/// 日志消息
/// </summary>
public string Message { get; set; } = string.Empty;
}

View File

@@ -17,8 +17,8 @@ public static class Expand
q.AddTrigger(opts => opts
.ForJob(emailJobKey)
.WithIdentity("EmailSyncTrigger")
.WithCronSchedule("0 0/20 * * * ?") // 每20分钟执行
.WithDescription("每20分钟同步一次邮件"));
.WithCronSchedule("0 0/10 * * * ?") // 每10分钟执行
.WithDescription("每10分钟同步一次邮件"));
// 配置周期性账单任务 - 每天早上6点执行
var periodicBillJobKey = new JobKey("PeriodicBillJob");