diff --git a/docs/example_custom_fields.log b/docs/example_custom_fields.log
new file mode 100644
index 0000000..16a6a7c
--- /dev/null
+++ b/docs/example_custom_fields.log
@@ -0,0 +1,10 @@
+[2025-01-15 08:00:01.123] [INFO] [C1 +0001] START TaskA modA : 初始化系统
+[2025-01-15 08:00:02.456] [DEBUG] [C1 +0002] WAIT TaskB modB : 等待资源分配
+[2025-01-15 08:00:03.789] [INFO] [C2 +0001] START TaskA modA : 开始处理请求
+[2025-01-15 08:00:04.012] [WARN] [C1 +0003] EMIT TaskC modC : 发送警告信号
+[2025-01-15 08:00:05.345] [ERROR] [C2 +0002] FAIL TaskB modB : 连接超时
+[2025-01-15 08:00:06.678] [INFO] [C1 +0004] PASS TaskA modA : 测试通过
+[2025-01-15 08:00:07.901] [DEBUG] [C3 +0001] READY TaskD modD : 就绪状态
+[2025-01-15 08:00:08.234] [INFO] [C2 +0003] END TaskC modC : 任务完成
+[2025-01-15 08:00:09.567] [ERROR] [C1 +0005] FAIL TaskB modB : 数据校验失败
+[2025-01-15 08:00:10.890] [INFO] [C3 +0002] START TaskA modA : 重新初始化
diff --git a/resources/icons/template.png b/resources/icons/template.png
new file mode 100644
index 0000000..2417d36
Binary files /dev/null and b/resources/icons/template.png differ
diff --git a/resources/resources.qrc b/resources/resources.qrc
index 604ae44..3099923 100644
--- a/resources/resources.qrc
+++ b/resources/resources.qrc
@@ -6,6 +6,7 @@
icons/filter.png
icons/toggle.png
icons/export.png
+ icons/template.png
diff --git a/src/core/logentry.h b/src/core/logentry.h
index f85b0bd..9138376 100644
--- a/src/core/logentry.h
+++ b/src/core/logentry.h
@@ -14,6 +14,7 @@
#define LOGENTRY_H
#include
+#include
#include
#include
@@ -75,10 +76,17 @@ struct LogEntry
*/
QString message;
+ QMap extraFields;
+
+ QString rawLine; // 原始行文本(未匹配时用于显示)
+ bool matched = true; // 是否匹配模板
+
bool operator==(const LogEntry& other) const
{
return timestamp == other.timestamp && level == other.level &&
- module == other.module && message == other.message;
+ module == other.module && message == other.message &&
+ extraFields == other.extraFields && rawLine == other.rawLine &&
+ matched == other.matched;
}
};
diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp
index ffa79ba..8c02f5a 100644
--- a/src/core/logexporter.cpp
+++ b/src/core/logexporter.cpp
@@ -20,6 +20,7 @@
#include
#include
#include
+#include
#include
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include
@@ -218,8 +219,12 @@ bool LogExporter::exportToTxt(const QList& logs,
// Write each log entry as a formatted line
for (int i = 0; i < logs.size(); ++i) {
const LogEntry& entry = logs[i];
- QString line = formatLogEntry(entry, config, ExportConfig::TXT);
- out << line << "\n";
+ if (!entry.matched) {
+ out << entry.rawLine << "\n";
+ } else {
+ QString line = formatLogEntry(entry, config, ExportConfig::TXT);
+ out << line << "\n";
+ }
if (i % 1000 == 0 || i == logs.size() - 1)
emitProgress(i + 1, logs.size());
@@ -272,13 +277,34 @@ bool LogExporter::exportToCsv(const QList& logs,
if (config.includeContent)
headers << QObject::tr("内容");
+ // Collect extra field names from all entries
+ QSet extraFieldNameSet;
+ for (const LogEntry& entry : logs) {
+ for (auto it = entry.extraFields.constBegin();
+ it != entry.extraFields.constEnd(); ++it) {
+ extraFieldNameSet.insert(it.key());
+ }
+ }
+ QStringList extraFieldNames(extraFieldNameSet.constBegin(), extraFieldNameSet.constEnd());
+ extraFieldNames.sort();
+ headers << extraFieldNames;
+
out << headers.join(",") << "\n";
// Write each log entry as a CSV row
+ int totalColumns = headers.size();
for (int i = 0; i < logs.size(); ++i) {
const LogEntry& entry = logs[i];
- QString line = formatLogEntry(entry, config, ExportConfig::CSV);
- out << line << "\n";
+ if (!entry.matched) {
+ // Unmatched line: rawLine in first column, rest empty
+ out << escapeForCsv(entry.rawLine);
+ for (int c = 1; c < totalColumns; ++c)
+ out << ",";
+ out << "\n";
+ } else {
+ QString line = formatLogEntry(entry, config, ExportConfig::CSV, extraFieldNames);
+ out << line << "\n";
+ }
if (i % 1000 == 0 || i == logs.size() - 1)
emitProgress(i + 1, logs.size());
@@ -337,26 +363,36 @@ bool LogExporter::exportToJson(const QList& logs,
return json.mid(1, json.size() - 2); // strip [ and ]
};
- bool first = true;
- auto writeField = [&](const QString& key, const QString& value) {
- if (!first)
- out << ",\n";
- first = false;
- out << " " << jsonEscape(key) << ": " << jsonEscape(value);
- };
-
- if (config.includeTimestamp) {
- writeField("timestamp",
- entry.timestamp.toString("yyyy-MM-dd HH:mm:ss.zzz"));
- }
- if (config.includeLevel) {
- writeField("level", entry.level);
- }
- if (config.includeModule) {
- writeField("module", entry.module);
- }
- if (config.includeContent) {
- writeField("content", entry.message);
+ if (!entry.matched) {
+ out << " " << jsonEscape("raw") << ": " << jsonEscape(entry.rawLine);
+ } else {
+ bool first = true;
+ auto writeField = [&](const QString& key, const QString& value) {
+ if (!first)
+ out << ",\n";
+ first = false;
+ out << " " << jsonEscape(key) << ": " << jsonEscape(value);
+ };
+
+ if (config.includeTimestamp) {
+ writeField("timestamp",
+ entry.timestamp.toString("yyyy-MM-dd HH:mm:ss.zzz"));
+ }
+ if (config.includeLevel) {
+ writeField("level", entry.level);
+ }
+ if (config.includeModule) {
+ writeField("module", entry.module);
+ }
+ if (config.includeContent) {
+ writeField("content", entry.message);
+ }
+
+ // Add extra fields
+ for (auto it = entry.extraFields.constBegin();
+ it != entry.extraFields.constEnd(); ++it) {
+ writeField(it.key(), it.value());
+ }
}
out << "\n }";
@@ -386,7 +422,8 @@ bool LogExporter::exportToJson(const QList& logs,
*/
QString LogExporter::formatLogEntry(const LogEntry& entry,
const ExportConfig& config,
- ExportConfig::Format format)
+ ExportConfig::Format format,
+ const QStringList& extraFieldNames)
{
QStringList fields;
@@ -422,6 +459,18 @@ QString LogExporter::formatLogEntry(const LogEntry& entry,
fields << content;
}
+ // Add extra fields (use known field names for consistent column count)
+ const QStringList& names = extraFieldNames.isEmpty()
+ ? QStringList(entry.extraFields.keys())
+ : extraFieldNames;
+ for (const QString& name : names) {
+ QString value = entry.extraFields.value(name);
+ if (format == ExportConfig::CSV) {
+ value = escapeForCsv(value);
+ }
+ fields << value;
+ }
+
// Join fields with appropriate separator
if (format == ExportConfig::CSV) {
return fields.join(",");
diff --git a/src/core/logexporter.h b/src/core/logexporter.h
index 6da3766..382d42c 100644
--- a/src/core/logexporter.h
+++ b/src/core/logexporter.h
@@ -269,7 +269,8 @@ class LogExporter : public QObject
* settings.
*/
QString formatLogEntry(const LogEntry& entry, const ExportConfig& config,
- ExportConfig::Format format);
+ ExportConfig::Format format,
+ const QStringList& extraFieldNames = QStringList());
/**
* @brief Escape special characters for CSV format
diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp
index 53b7972..19608b7 100644
--- a/src/core/logformattemplate.cpp
+++ b/src/core/logformattemplate.cpp
@@ -16,7 +16,7 @@ const QStringList LogFormatTemplate::KNOWN_FIELDS = {
// Common log level names
static const QSet LOG_LEVELS = {
"TRACE", "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "FATAL", "SEVERE",
- "START", "END", "EMIT", "WAIT", "READY", "PASS", "FAIL"
+ "START", "END", "EMIT", "WAIT", "READY", "PASS", "FAIL", "FAULT"
};
// Timestamp patterns
@@ -67,6 +67,22 @@ QString LogFormatTemplate::errorMessage() const
return m_errorMessage;
}
+QStringList LogFormatTemplate::allFieldNames() const
+{
+ return m_captureMap.keys();
+}
+
+QStringList LogFormatTemplate::extraFieldNames() const
+{
+ QStringList result;
+ for (auto it = m_captureMap.constBegin(); it != m_captureMap.constEnd(); ++it) {
+ if (!KNOWN_FIELDS.contains(it.key())) {
+ result.append(it.key());
+ }
+ }
+ return result;
+}
+
void LogFormatTemplate::compile()
{
m_captureMap.clear();
@@ -83,6 +99,7 @@ void LogFormatTemplate::compile()
int captureCount = 0;
int i = 0;
const int len = m_template.length();
+ bool prevWasPlaceholder = false;
while (i < len) {
QChar ch = m_template[i];
@@ -92,6 +109,7 @@ void LogFormatTemplate::compile()
QChar next = m_template[i + 1];
if (next == '{' || next == '}' || next == '\\') {
regexStr += QRegularExpression::escape(QString(next));
+ prevWasPlaceholder = false;
i += 2;
continue;
}
@@ -106,11 +124,6 @@ void LogFormatTemplate::compile()
}
QString fieldName = m_template.mid(i + 1, closeBrace - i - 1);
- if (!KNOWN_FIELDS.contains(fieldName)) {
- m_errorMessage =
- QStringLiteral("Unknown placeholder '{%1}'").arg(fieldName);
- return;
- }
if (m_captureMap.contains(fieldName)) {
m_errorMessage =
QStringLiteral("Duplicate placeholder '{%1}'").arg(fieldName);
@@ -125,15 +138,66 @@ void LogFormatTemplate::compile()
} else if (fieldName == QStringLiteral("timestamp")) {
regexStr += QStringLiteral("(.*?)");
} else {
- regexStr += QStringLiteral("(\\S+)");
+ // Check if this placeholder is inside brackets
+ // by looking at the preceding non-whitespace in the regex
+ bool insideBrackets = false;
+ for (int j = regexStr.length() - 1; j >= 0; --j) {
+ if (regexStr[j].isSpace())
+ continue;
+ if (regexStr[j] == QLatin1Char('[') &&
+ j > 0 && regexStr[j - 1] == QLatin1Char('\\')) {
+ insideBrackets = true;
+ }
+ break;
+ }
+ if (insideBrackets) {
+ // Inside brackets: match everything except ']'
+ // PCRE: [^]] means "not ]", the first ] closes the class
+ regexStr += QChar::fromLatin1('(');
+ regexStr += QChar::fromLatin1('[');
+ regexStr += QChar::fromLatin1('^');
+ regexStr += QChar::fromLatin1(']');
+ regexStr += QChar::fromLatin1(']');
+ regexStr += QChar::fromLatin1('+');
+ regexStr += QChar::fromLatin1(')');
+ } else {
+ // Match non-whitespace: need \\S in the regex string
+ regexStr += QChar::fromLatin1('(');
+ regexStr += QChar::fromLatin1('\\');
+ regexStr += QChar::fromLatin1('S');
+ regexStr += QChar::fromLatin1('+');
+ regexStr += QChar::fromLatin1(')');
+ }
}
+ prevWasPlaceholder = true;
i = closeBrace + 1;
continue;
}
+ // Handle whitespace: use \s+ between placeholders for flexible spacing
+ if (ch.isSpace()) {
+ // Collect the run of whitespace
+ int spaceStart = i;
+ while (i < len && m_template[i].isSpace())
+ i++;
+
+ // Check if next non-space char starts a placeholder
+ bool nextIsPlaceholder = (i < len && m_template[i] == '{');
+
+ if (prevWasPlaceholder && nextIsPlaceholder) {
+ // Between two placeholders: use \s+ for flexible matching
+ regexStr += QStringLiteral("\\s+");
+ } else {
+ // Literal whitespace: preserve as-is
+ regexStr += QRegularExpression::escape(m_template.mid(spaceStart, i - spaceStart));
+ }
+ continue;
+ }
+
// Literal character: escape for regex
regexStr += QRegularExpression::escape(QString(ch));
+ prevWasPlaceholder = false;
i++;
}
@@ -155,11 +219,39 @@ void LogFormatTemplate::compile()
LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines)
{
+ return LogFormatTemplate(detectWithInfo(sampleLines).templateStr);
+}
+
+LogFormatTemplate::DetectInfo LogFormatTemplate::detectWithInfo(const QStringList& sampleLines)
+{
+ DetectInfo info;
if (sampleLines.isEmpty()) {
- return LogFormatTemplate();
+ info.reason = QStringLiteral("无样本行");
+ return info;
+ }
+
+ int threshold = qMax(1, sampleLines.size() / 5);
+ int totalLines = sampleLines.size();
+
+ // Step 1: Smart analysis - detect field positions dynamically.
+ QString smartTemplate = analyzeLineStructure(sampleLines);
+ int smartMatchCount = 0;
+ int smartFieldCount = 0;
+ if (!smartTemplate.isEmpty()) {
+ LogFormatTemplate fmt(smartTemplate);
+ if (fmt.isValid()) {
+ smartFieldCount = fmt.allFieldNames().size();
+ for (const QString& line : sampleLines) {
+ if (fmt.regex().match(line).hasMatch()) {
+ smartMatchCount++;
+ }
+ }
+ }
}
- // Step 1: Try preset templates
+ // Step 2: Find best preset (for fallback comparison)
+ static const QRegularExpression tsValidation(
+ R"(\d{4}[-/]\d{2}[-/]\d{2})");
const auto presetList = presets();
int bestPresetIndex = -1;
int bestPresetCount = 0;
@@ -169,11 +261,18 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines)
if (!fmt.isValid())
continue;
+ int tsIdx = fmt.captureIndex("timestamp");
int matchCount = 0;
for (const QString& line : sampleLines) {
- if (fmt.regex().match(line).hasMatch()) {
- matchCount++;
+ QRegularExpressionMatch m = fmt.regex().match(line);
+ if (!m.hasMatch())
+ continue;
+ if (tsIdx >= 0) {
+ QString ts = m.captured(tsIdx).trimmed();
+ if (!tsValidation.match(ts).hasMatch())
+ continue;
}
+ matchCount++;
}
if (matchCount > bestPresetCount) {
@@ -182,50 +281,102 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines)
}
}
- int threshold = qMax(1, sampleLines.size() / 5);
- if (bestPresetIndex >= 0 && bestPresetCount >= threshold) {
- return LogFormatTemplate(presetList[bestPresetIndex].templateStr);
- }
+ // Step 3: Choose the best template.
+ if (smartMatchCount >= threshold && smartFieldCount > 0) {
+ int presetFieldCount = (bestPresetIndex >= 0)
+ ? LogFormatTemplate(presetList[bestPresetIndex].templateStr).allFieldNames().size()
+ : 0;
+
+ // Prefer preset when it matches well — it preserves semantic field
+ // names (e.g. module) that smart detection may replace with generic
+ // placeholders (e.g. field1).
+ if (bestPresetIndex >= 0 && bestPresetCount >= threshold
+ && presetFieldCount >= smartFieldCount) {
+ info.templateStr = presetList[bestPresetIndex].templateStr;
+ info.reason = QStringLiteral("使用预设'%1', 匹配%2/%3行")
+ .arg(presetList[bestPresetIndex].name)
+ .arg(bestPresetCount)
+ .arg(totalLines);
+ return info;
+ }
- // Step 2: Smart analysis - try to detect field positions dynamically
- QString smartTemplate = analyzeLineStructure(sampleLines);
- if (!smartTemplate.isEmpty()) {
- LogFormatTemplate fmt(smartTemplate);
- if (fmt.isValid()) {
- int matchCount = 0;
- for (const QString& line : sampleLines) {
- if (fmt.regex().match(line).hasMatch()) {
- matchCount++;
- }
- }
- if (matchCount >= threshold) {
- return fmt;
- }
+ if (smartFieldCount > presetFieldCount) {
+ info.templateStr = smartTemplate;
+ int extra = smartFieldCount - 4; // subtract known fields
+ info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行")
+ .arg(smartFieldCount)
+ .arg(smartMatchCount)
+ .arg(totalLines);
+ if (extra > 0)
+ info.reason += QStringLiteral(", 含%1个自定义字段").arg(extra);
+ return info;
}
+ info.templateStr = smartTemplate;
+ info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行")
+ .arg(smartFieldCount)
+ .arg(smartMatchCount)
+ .arg(totalLines);
+ return info;
+ }
+
+ // Step 4: Smart didn't work — use best preset
+ if (bestPresetIndex >= 0 && bestPresetCount >= threshold) {
+ info.templateStr = presetList[bestPresetIndex].templateStr;
+ info.reason = QStringLiteral("使用预设'%1', 匹配%2/%3行")
+ .arg(presetList[bestPresetIndex].name)
+ .arg(bestPresetCount)
+ .arg(totalLines);
+ return info;
}
- // Step 3: Fallback - timestamp only
+ // Step 5: Fallback - timestamp only (with validation)
LogFormatTemplate fallback(QStringLiteral("[{timestamp}] {message}"));
+ int fbTsIdx = fallback.captureIndex("timestamp");
int fallbackCount = 0;
for (const QString& line : sampleLines) {
- if (fallback.regex().match(line).hasMatch()) {
- fallbackCount++;
+ QRegularExpressionMatch m = fallback.regex().match(line);
+ if (!m.hasMatch())
+ continue;
+ if (fbTsIdx >= 0) {
+ QString ts = m.captured(fbTsIdx).trimmed();
+ if (!tsValidation.match(ts).hasMatch())
+ continue;
}
+ fallbackCount++;
}
if (fallbackCount >= threshold) {
- return fallback;
+ info.templateStr = QStringLiteral("[{timestamp}] {message}");
+ info.reason = QStringLiteral("回退: 仅检测到时间戳, 匹配%1/%2行")
+ .arg(fallbackCount)
+ .arg(totalLines);
+ return info;
}
- return LogFormatTemplate();
+ info.reason = QStringLiteral("无法识别日志格式");
+ return info;
}
QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines)
{
- // Find lines that look like actual log entries (with timestamp)
+ // Find lines that look like actual log entries (with timestamp),
+ // skipping separator/banner lines (e.g. "===...", "---...")
QStringList logLines;
for (const QString& line : lines) {
if (TS_BRACKETED.match(line).hasMatch() || TS_BARE.match(line).hasMatch()) {
- logLines.append(line);
+ // Skip separator lines: content after timestamp has no letters
+ QRegularExpressionMatch tsMatch = TS_BRACKETED.match(line);
+ if (!tsMatch.hasMatch())
+ tsMatch = TS_BARE.match(line);
+ QString afterTs = line.mid(tsMatch.capturedEnd()).trimmed();
+ bool hasLetter = false;
+ for (const QChar& c : afterTs) {
+ if (c.isLetter()) {
+ hasLetter = true;
+ break;
+ }
+ }
+ if (hasLetter)
+ logLines.append(line);
}
}
@@ -233,87 +384,114 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines)
return QString();
}
- // Analyze first few log lines to determine structure
int analyzeCount = qMin(10, logLines.size());
- QMap fieldPositions; // field name -> position count
- for (int i = 0; i < analyzeCount; ++i) {
- const QString& line = logLines[i];
-
- // Find timestamp
- QRegularExpressionMatch tsMatch;
- bool tsBracketed = true;
- tsMatch = TS_BRACKETED.match(line);
- if (!tsMatch.hasMatch()) {
- tsMatch = TS_BARE.match(line);
- tsBracketed = false;
+ // Determine timestamp format
+ bool useBracketedTs = false;
+ for (int i = 0; i < qMin(5, logLines.size()); ++i) {
+ if (TS_BRACKETED.match(logLines[i]).hasMatch()) {
+ useBracketedTs = true;
+ break;
}
+ }
- if (!tsMatch.hasMatch())
- continue;
+ // Parse first line to determine structure
+ QRegularExpressionMatch firstTsMatch;
+ firstTsMatch = useBracketedTs ? TS_BRACKETED.match(logLines[0])
+ : TS_BARE.match(logLines[0]);
+ if (!firstTsMatch.hasMatch())
+ return QString();
- int tsStart = tsMatch.capturedStart();
- int tsEnd = tsMatch.capturedEnd();
+ QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed();
- // Get content after timestamp
- QString afterTs = line.mid(tsEnd).trimmed();
+ // Collect bracketed fields
+ QRegularExpression bracketRe(R"(\[([^\]]+)\])");
+ int pos = 0;
+ struct BracketInfo { QString content; bool isLevel; };
+ QList bracketFields;
- // Check if there's a second bracketed field (level or cycle info)
- QRegularExpression bracketField(R"(^\s*\[([^\]]+)\])");
- QRegularExpressionMatch bracketMatch = bracketField.match(afterTs);
+ while (pos < afterTs.length()) {
+ QRegularExpressionMatch m = bracketRe.match(afterTs, pos);
+ if (!m.hasMatch())
+ break;
- QString level;
- QString rest;
+ // Check there's only whitespace between previous end and this match
+ QString between = afterTs.mid(pos, m.capturedStart() - pos).trimmed();
+ if (!between.isEmpty())
+ break; // Non-bracket content found, stop
- if (bracketMatch.hasMatch()) {
- // Has bracketed field after timestamp
- level = bracketMatch.captured(1).trimmed();
- rest = afterTs.mid(bracketMatch.capturedEnd()).trimmed();
- } else {
- // No bracket - check for bare level word
- QStringList words = afterTs.split(QRegularExpression("\\s+"),
- Qt::SkipEmptyParts);
- if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) {
- level = words[0];
- rest = words.mid(1).join(" ");
- }
- }
+ QString content = m.captured(1).trimmed();
+ bool isLevel = !content.contains(' ') &&
+ LOG_LEVELS.contains(content.toUpper());
+ bracketFields.append({content, isLevel});
+ pos = m.capturedEnd();
+ }
- // Try to detect module name (first word of rest that looks like identifier)
- if (!rest.isEmpty()) {
- QStringList words = rest.split(QRegularExpression("\\s+"),
- Qt::SkipEmptyParts);
- if (!words.isEmpty()) {
- QString firstWord = words[0];
- // Check if it looks like a module name (alphanumeric, no special chars)
- if (QRegularExpression("^[A-Za-z][A-Za-z0-9_]*$").match(firstWord)
- .hasMatch()) {
- // Likely a module name
- if (!level.isEmpty()) {
- fieldPositions["has_module"]++;
- }
- }
- }
+ // Get remaining text after all brackets
+ QString afterBrackets = afterTs.mid(pos).trimmed();
+ QStringList remainingWords = afterBrackets.split(QRegularExpression("\\s+"),
+ Qt::SkipEmptyParts);
+
+ // Analyze remaining words to identify fields
+ // Strategy: check multiple lines to find consistent word count before key=value pairs
+ int wordsBeforeKV = remainingWords.size(); // default: all words are fields
+ for (int wi = 0; wi < remainingWords.size(); ++wi) {
+ if (remainingWords[wi].contains('=')) {
+ wordsBeforeKV = wi;
+ break;
}
+ }
- if (!level.isEmpty()) {
- fieldPositions["has_level"]++;
- }
+ // Check if the first remaining word is a known log level/state
+ bool hasBareLevel = false;
+ if (!remainingWords.isEmpty() &&
+ LOG_LEVELS.contains(remainingWords[0].toUpper())) {
+ hasBareLevel = true;
}
- // Build template based on analysis
- bool hasLevel = fieldPositions.value("has_level", 0) > analyzeCount / 2;
- bool hasModule = fieldPositions.value("has_module", 0) > analyzeCount / 2;
+ // Validate across multiple lines: count how many have the same bracket count
+ int bracketCount = bracketFields.size();
+ int consistentBracketCount = 0;
+ int consistentBareLevel = 0;
+ int consistentWordsBeforeKV = 0;
- // Determine timestamp format
- bool useBracketedTs = false;
- for (int i = 0; i < qMin(5, logLines.size()); ++i) {
- if (TS_BRACKETED.match(logLines[i]).hasMatch()) {
- useBracketedTs = true;
- break;
+ for (int i = 0; i < analyzeCount; ++i) {
+ QRegularExpressionMatch tsM = useBracketedTs
+ ? TS_BRACKETED.match(logLines[i])
+ : TS_BARE.match(logLines[i]);
+ if (!tsM.hasMatch())
+ continue;
+ QString after = logLines[i].mid(tsM.capturedEnd()).trimmed();
+
+ // Count brackets
+ int bc = 0;
+ int p = 0;
+ while (p < after.length()) {
+ QRegularExpressionMatch bm = bracketRe.match(after, p);
+ if (!bm.hasMatch()) break;
+ QString gap = after.mid(p, bm.capturedStart() - p).trimmed();
+ if (!gap.isEmpty()) break;
+ bc++;
+ p = bm.capturedEnd();
+ }
+ if (bc == bracketCount) consistentBracketCount++;
+
+ // Check bare level
+ QString rest = after.mid(p).trimmed();
+ QStringList words = rest.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
+ if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) {
+ consistentBareLevel++;
}
+
+ // Count words before first key=value
+ int wkv = words.size();
+ for (int wi = 0; wi < words.size(); ++wi) {
+ if (words[wi].contains('=')) { wkv = wi; break; }
+ }
+ if (wkv == wordsBeforeKV) consistentWordsBeforeKV++;
}
+ // Build template
QString templateStr;
if (useBracketedTs) {
templateStr = "[{timestamp}]";
@@ -321,36 +499,65 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines)
templateStr = "{timestamp}";
}
- if (hasLevel) {
- // Check if level is in brackets
- QRegularExpression bracketLevel(R"(\[\s*\w+\s*\])");
- bool levelInBrackets = false;
- for (int i = 0; i < qMin(5, logLines.size()); ++i) {
- QRegularExpressionMatch tsMatch = useBracketedTs
- ? TS_BRACKETED.match(logLines[i])
- : TS_BARE.match(logLines[i]);
- if (tsMatch.hasMatch()) {
- QString afterTs = logLines[i].mid(tsMatch.capturedEnd()).trimmed();
- if (bracketLevel.match(afterTs).hasMatch()) {
- levelInBrackets = true;
- break;
- }
- }
- }
-
- if (levelInBrackets) {
+ // Add bracketed fields
+ int extraFieldIdx = 1;
+ bool levelPlaced = false;
+ for (const BracketInfo& bi : bracketFields) {
+ if (bi.isLevel && !levelPlaced) {
templateStr += " [{level}]";
+ levelPlaced = true;
} else {
- templateStr += " {level}";
+ templateStr += QString(" [{field%1}]").arg(extraFieldIdx);
+ extraFieldIdx++;
}
}
- if (hasModule) {
- templateStr += " {module}";
+ // Build a base template (bracket fields only, no bare word fields)
+ QString baseTemplate = templateStr + " {message}";
+
+ // Try adding bare level if found consistently
+ if (!levelPlaced && consistentBareLevel > analyzeCount / 2) {
+ templateStr += " {level}";
+ levelPlaced = true;
+ }
+
+ // Add remaining words as fields (before key=value part)
+ if (consistentWordsBeforeKV > analyzeCount / 2) {
+ int startWord = hasBareLevel && levelPlaced ? 1 : 0;
+ // First word after level is typically the module/task ID
+ if (startWord < wordsBeforeKV) {
+ templateStr += " {module}";
+ startWord++;
+ }
+ for (int wi = startWord; wi < wordsBeforeKV; ++wi) {
+ templateStr += QString(" {field%1}").arg(extraFieldIdx);
+ extraFieldIdx++;
+ }
}
templateStr += " {message}";
+ // Validate: if the detailed template doesn't match well due to
+ // multi-space alignment or other issues, fall back to the simpler base
+ if (!bracketFields.isEmpty()) {
+ LogFormatTemplate detailed(templateStr);
+ LogFormatTemplate simple(baseTemplate);
+ if (detailed.isValid() && simple.isValid()) {
+ int detailedCount = 0;
+ int simpleCount = 0;
+ for (int i = 0; i < analyzeCount; ++i) {
+ if (detailed.regex().match(logLines[i]).hasMatch())
+ detailedCount++;
+ if (simple.regex().match(logLines[i]).hasMatch())
+ simpleCount++;
+ }
+ // Use the simpler template if it matches significantly better
+ if (simpleCount > detailedCount) {
+ return baseTemplate;
+ }
+ }
+ }
+
return templateStr;
}
diff --git a/src/core/logformattemplate.h b/src/core/logformattemplate.h
index 365799b..9c58f58 100644
--- a/src/core/logformattemplate.h
+++ b/src/core/logformattemplate.h
@@ -13,6 +13,11 @@ class LogFormatTemplate {
QString templateStr;
};
+ struct DetectInfo {
+ QString templateStr;
+ QString reason;
+ };
+
LogFormatTemplate();
explicit LogFormatTemplate(const QString& templateStr);
@@ -23,14 +28,18 @@ class LogFormatTemplate {
bool isValid() const;
QString errorMessage() const;
+ QStringList allFieldNames() const;
+ QStringList extraFieldNames() const;
+
static LogFormatTemplate detect(const QStringList& sampleLines);
+ static DetectInfo detectWithInfo(const QStringList& sampleLines);
static QList presets();
+ static QString analyzeLineStructure(const QStringList& lines);
static const QString DEFAULT_TEMPLATE;
static const QStringList KNOWN_FIELDS;
private:
void compile();
- static QString analyzeLineStructure(const QStringList& lines);
QString m_template;
QRegularExpression m_regex;
diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp
index eff98f8..07897ab 100644
--- a/src/core/logloader.cpp
+++ b/src/core/logloader.cpp
@@ -62,9 +62,17 @@ void LogLoader::process()
sampleLines.append(in.readLine());
}
in.seek(startPos);
- fmt = LogFormatTemplate::detect(sampleLines);
+ LogFormatTemplate::DetectInfo detectInfo =
+ LogFormatTemplate::detectWithInfo(sampleLines);
+ if (detectInfo.templateStr.isEmpty()) {
+ detectInfo.templateStr = LogFormatTemplate::DEFAULT_TEMPLATE;
+ detectInfo.reason = QObject::tr("自动检测失败,使用默认模板");
+ }
+ fmt = LogFormatTemplate(detectInfo.templateStr);
+ emit detectInfoReady(detectInfo.templateStr, detectInfo.reason);
} else {
fmt = LogFormatTemplate(m_formatTemplate);
+ emit detectInfoReady(m_formatTemplate, QString());
}
if (!fmt.isValid()) {
@@ -78,11 +86,24 @@ void LogLoader::process()
QVector buffer;
buffer.reserve(m_chunkSize);
+ // Cache capture indices outside the loop
+ int tsIdx = fmt.captureIndex("timestamp");
+ int lvIdx = fmt.captureIndex("level");
+ int modIdx = fmt.captureIndex("module");
+ int msgIdx = fmt.captureIndex("message");
+ QStringList extraFieldNames = fmt.extraFieldNames();
+ QVector extraIdxList;
+ extraIdxList.reserve(extraFieldNames.size());
+ for (const QString& fn : extraFieldNames) {
+ extraIdxList.append(fmt.captureIndex(fn));
+ }
+
bool hasTime = false;
QDateTime minTime;
QDateTime maxTime;
QSet modulesSet;
QSet levelsSet;
+ QMap> extraFieldSets;
qint64 totalBytes = file.size();
qint64 processedBytes = 0;
@@ -98,19 +119,14 @@ void LogLoader::process()
QRegularExpressionMatch match = regex.match(line);
if (match.hasMatch()) {
LogEntry entry;
- int tsIdx = fmt.captureIndex("timestamp");
- int lvIdx = fmt.captureIndex("level");
- int modIdx = fmt.captureIndex("module");
- int msgIdx = fmt.captureIndex("message");
+ entry.rawLine = line;
+ entry.matched = true;
if (tsIdx >= 0) {
- entry.timestamp = QDateTime::fromString(
- match.captured(tsIdx).trimmed(),
- "yyyy-MM-dd HH:mm:ss.zzz");
+ QString tsStr = match.captured(tsIdx).trimmed();
+ entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss.zzz");
if (!entry.timestamp.isValid()) {
- entry.timestamp = QDateTime::fromString(
- match.captured(tsIdx).trimmed(),
- "yyyy-MM-dd HH:mm:ss");
+ entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss");
}
}
entry.level = (lvIdx >= 0) ? match.captured(lvIdx).trimmed()
@@ -120,6 +136,15 @@ void LogLoader::process()
entry.message = (msgIdx >= 0) ? match.captured(msgIdx)
: QString();
+ for (int ei = 0; ei < extraFieldNames.size(); ++ei) {
+ int idx = extraIdxList[ei];
+ if (idx >= 0) {
+ QString value = match.captured(idx).trimmed();
+ entry.extraFields[extraFieldNames[ei]] = value;
+ extraFieldSets[extraFieldNames[ei]].insert(value);
+ }
+ }
+
if (entry.timestamp.isValid()) {
if (!hasTime) {
minTime = maxTime = entry.timestamp;
@@ -131,19 +156,27 @@ void LogLoader::process()
maxTime = entry.timestamp;
}
}
- modulesSet.insert(entry.module);
- levelsSet.insert(entry.level);
+ if (!entry.module.isEmpty())
+ modulesSet.insert(entry.module);
+ if (!entry.level.isEmpty())
+ levelsSet.insert(entry.level);
buffer.append(entry);
- if (buffer.size() >= m_chunkSize) {
- emit chunkReady(buffer);
- buffer.clear();
- // 仅在块处理完成时计算和发射进度,减少计算频率
- if (totalBytes > 0) {
- int percent =
- static_cast((processedBytes * 100) / totalBytes);
- emit progress(percent);
- }
+ } else {
+ // Unmatched line: preserve as raw text
+ LogEntry entry;
+ entry.rawLine = line;
+ entry.matched = false;
+ buffer.append(entry);
+ }
+
+ if (buffer.size() >= m_chunkSize) {
+ emit chunkReady(buffer);
+ buffer.clear();
+ if (totalBytes > 0) {
+ int percent =
+ static_cast((processedBytes * 100) / totalBytes);
+ emit progress(percent);
}
}
}
@@ -160,7 +193,15 @@ void LogLoader::process()
QStringList levels = QStringList(levelsSet.cbegin(), levelsSet.cend());
modules.sort(Qt::CaseInsensitive);
levels.sort(Qt::CaseInsensitive);
- emit summaryReady(minTime, maxTime, modules, levels);
+
+ QMap extraFieldValues;
+ for (auto it = extraFieldSets.constBegin(); it != extraFieldSets.constEnd(); ++it) {
+ QStringList values = QStringList(it.value().cbegin(), it.value().cend());
+ values.sort(Qt::CaseInsensitive);
+ extraFieldValues[it.key()] = values;
+ }
+
+ emit summaryReady(minTime, maxTime, modules, levels, extraFieldNames, extraFieldValues);
emit progress(100);
emit finished();
}
diff --git a/src/core/logloader.h b/src/core/logloader.h
index 7688bda..cf8e894 100644
--- a/src/core/logloader.h
+++ b/src/core/logloader.h
@@ -3,6 +3,7 @@
#include
#include
+#include
#include
#include
#include
@@ -31,7 +32,10 @@ public slots:
void chunkReady(QVector chunk);
void progress(int percentage);
void summaryReady(const QDateTime& minTime, const QDateTime& maxTime,
- const QStringList& modules, const QStringList& levels);
+ const QStringList& modules, const QStringList& levels,
+ const QStringList& extraFieldNames,
+ const QMap& extraFieldValues);
+ void detectInfoReady(const QString& templateStr, const QString& reason);
void finished();
void error(const QString& message);
diff --git a/src/main.cpp b/src/main.cpp
index 8e26cba..cc440ce 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -70,6 +70,7 @@ int main(int argc, char* argv[])
// Register metatypes for cross-thread signal-slot connections
qRegisterMetaType("LogEntry");
qRegisterMetaType>("QVector");
+ qRegisterMetaType>("QMap");
// Initialize language management system
LanguageManager::instance().initialize();
diff --git a/src/ui/highlightdelegate.cpp b/src/ui/highlightdelegate.cpp
index 2199477..f42a664 100644
--- a/src/ui/highlightdelegate.cpp
+++ b/src/ui/highlightdelegate.cpp
@@ -24,8 +24,8 @@ void HighlightDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opt
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
- // Only highlight on the content column (last column)
- if (m_text.isEmpty() || index.column() != index.model()->columnCount() - 1) {
+ // Only highlight on the message column (fixed index 4)
+ if (m_text.isEmpty() || index.column() != 4) {
QStyledItemDelegate::paint(painter, opt, index);
return;
}
diff --git a/src/ui/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp
index 181672c..9bcae50 100644
--- a/src/ui/logfilterproxymodel.cpp
+++ b/src/ui/logfilterproxymodel.cpp
@@ -1,5 +1,6 @@
#include "logfilterproxymodel.h"
+#include
#include
#include "logtablemodel.h"
@@ -20,17 +21,43 @@ void LogFilterProxyModel::setTimeRange(const QDateTime& start, const QDateTime&
void LogFilterProxyModel::setLevels(const QStringList& levels)
{
m_levelSet = QSet(levels.cbegin(), levels.cend());
+ m_levelFilterActive = true;
invalidateFilter();
}
void LogFilterProxyModel::setModules(const QStringList& modules)
{
m_moduleSet = QSet(modules.cbegin(), modules.cend());
+ m_moduleFilterActive = true;
+ invalidateFilter();
+}
+
+void LogFilterProxyModel::setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues)
+{
+ m_extraFilters[fieldName] = acceptedValues;
+ invalidateFilter();
+}
+
+void LogFilterProxyModel::clearExtraFieldFilters()
+{
+ m_extraFilters.clear();
+ invalidateFilter();
+}
+
+void LogFilterProxyModel::setHideUnmatched(bool hide)
+{
+ m_hideUnmatched = hide;
invalidateFilter();
}
bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& source_parent) const
{
+ // Unmatched lines: hide if m_hideUnmatched is set, otherwise always pass
+ QModelIndex msgIndex = sourceModel()->index(source_row, LogTableModel::ColumnMessage, source_parent);
+ bool matched = sourceModel()->data(msgIndex, LogTableModel::MatchedRole).toBool();
+ if (!matched)
+ return !m_hideUnmatched;
+
QModelIndex tsIndex = sourceModel()->index(source_row, LogTableModel::ColumnTimestamp, source_parent);
QModelIndex lvlIndex = sourceModel()->index(source_row, LogTableModel::ColumnLevel, source_parent);
QModelIndex modIndex = sourceModel()->index(source_row, LogTableModel::ColumnModule, source_parent);
@@ -45,12 +72,22 @@ bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& so
return false;
}
- if (!m_levelSet.isEmpty() && !m_levelSet.contains(level))
+ if (m_levelFilterActive && !m_levelSet.contains(level))
return false;
- if (!m_moduleSet.isEmpty() && !m_moduleSet.contains(module))
+ if (m_moduleFilterActive && !m_moduleSet.contains(module))
return false;
+ if (!m_extraFilters.isEmpty()) {
+ QModelIndex extraIndex = sourceModel()->index(source_row, LogTableModel::ColumnMessage, source_parent);
+ QMap extraFields = sourceModel()->data(extraIndex, LogTableModel::ExtraFieldsRole).value>();
+ for (auto it = m_extraFilters.constBegin(); it != m_extraFilters.constEnd(); ++it) {
+ QString value = extraFields.value(it.key());
+ if (!it.value().contains(value))
+ return false;
+ }
+ }
+
return true;
}
diff --git a/src/ui/logfilterproxymodel.h b/src/ui/logfilterproxymodel.h
index 26295da..a935980 100644
--- a/src/ui/logfilterproxymodel.h
+++ b/src/ui/logfilterproxymodel.h
@@ -2,6 +2,7 @@
#define LOGFILTERPROXYMODEL_H
#include
+#include
#include
#include
#include
@@ -20,6 +21,9 @@ class LogFilterProxyModel : public QSortFilterProxyModel
void setTimeRange(const QDateTime& start, const QDateTime& end);
void setLevels(const QStringList& levels);
void setModules(const QStringList& modules);
+ void setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues);
+ void clearExtraFieldFilters();
+ void setHideUnmatched(bool hide);
protected:
bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override;
@@ -28,7 +32,11 @@ class LogFilterProxyModel : public QSortFilterProxyModel
QDateTime m_start;
QDateTime m_end;
QSet m_levelSet;
+ bool m_levelFilterActive = false;
QSet m_moduleSet;
+ bool m_moduleFilterActive = false;
+ QMap> m_extraFilters;
+ bool m_hideUnmatched = false;
};
#endif // LOGFILTERPROXYMODEL_H
diff --git a/src/ui/logtablemodel.cpp b/src/ui/logtablemodel.cpp
index e26c08f..263738d 100644
--- a/src/ui/logtablemodel.cpp
+++ b/src/ui/logtablemodel.cpp
@@ -19,7 +19,7 @@ int LogTableModel::columnCount(const QModelIndex& parent) const
{
if (parent.isValid())
return 0;
- return ColumnCount;
+ return ColumnCount + m_extraColumns.size();
}
QVariant LogTableModel::data(const QModelIndex& index, int role) const
@@ -34,6 +34,21 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const
const LogEntry& entry = m_entries.at(row);
+ if (role == MatchedRole)
+ return entry.matched;
+
+ if (!entry.matched) {
+ // Unmatched line: show rawLine in message column, line number only
+ if (role == Qt::DisplayRole) {
+ if (col == ColumnLine)
+ return row + 1;
+ if (col == ColumnMessage)
+ return entry.rawLine;
+ return QVariant();
+ }
+ return QVariant();
+ }
+
if (role == Qt::DisplayRole) {
switch (col) {
case ColumnLine:
@@ -47,6 +62,10 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const
case ColumnMessage:
return entry.message;
default:
+ if (col >= ColumnCount && col < ColumnCount + m_extraColumns.size()) {
+ int extraIdx = col - ColumnCount;
+ return entry.extraFields.value(m_extraColumns[extraIdx]);
+ }
return QVariant();
}
}
@@ -59,6 +78,8 @@ QVariant LogTableModel::data(const QModelIndex& index, int role) const
return entry.module;
if (role == MessageRole)
return entry.message;
+ if (role == ExtraFieldsRole)
+ return QVariant::fromValue(entry.extraFields);
return QVariant();
}
@@ -78,6 +99,9 @@ QVariant LogTableModel::headerData(int section, Qt::Orientation orientation, int
case ColumnMessage:
return QObject::tr("内容");
default:
+ if (section >= ColumnCount && section < ColumnCount + m_extraColumns.size()) {
+ return m_extraColumns[section - ColumnCount];
+ }
break;
}
}
@@ -116,4 +140,11 @@ const LogEntry& LogTableModel::at(int row) const
return m_entries.at(row);
}
+void LogTableModel::setExtraColumns(const QStringList& fieldNames)
+{
+ beginResetModel();
+ m_extraColumns = fieldNames;
+ endResetModel();
+}
+
diff --git a/src/ui/logtablemodel.h b/src/ui/logtablemodel.h
index d8f95cc..5cc6724 100644
--- a/src/ui/logtablemodel.h
+++ b/src/ui/logtablemodel.h
@@ -2,6 +2,7 @@
#define LOGTABLEMODEL_H
#include
+#include
#include
#include
@@ -35,7 +36,9 @@ class LogTableModel : public QAbstractTableModel
TimestampRole = Qt::UserRole + 1,
LevelRole,
ModuleRole,
- MessageRole
+ MessageRole,
+ ExtraFieldsRole,
+ MatchedRole
};
explicit LogTableModel(QObject* parent = nullptr);
@@ -53,8 +56,13 @@ class LogTableModel : public QAbstractTableModel
const LogEntry& at(int row) const;
int size() const { return m_entries.size(); }
+ // Extra columns
+ void setExtraColumns(const QStringList& fieldNames);
+ QStringList extraColumns() const { return m_extraColumns; }
+
private:
QVector m_entries;
+ QStringList m_extraColumns;
};
#endif // LOGTABLEMODEL_H
diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp
index f247bee..e0c200f 100644
--- a/src/ui/logviewer.cpp
+++ b/src/ui/logviewer.cpp
@@ -45,6 +45,7 @@
#include "logtablemodel.h"
#include "../core/logloader.h"
+#include "../core/logformattemplate.h"
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include
#else
@@ -82,6 +83,10 @@ LogViewer::LogViewer(QWidget* parent)
sourceModel(nullptr),
proxyModel(nullptr),
highlightDelegate(nullptr),
+ formatModeCombo(nullptr),
+ formatModeLabel(nullptr),
+ hideUnmatchedCheckBox(nullptr),
+ templateInfoLabel(nullptr),
currentSearchIndex(-1),
searchDebounceTimer(nullptr)
{
@@ -202,62 +207,6 @@ void LogViewer::setupUI()
timeGroupBox = new QGroupBox(tr("时间范围"), this);
timeGroupBox->setLayout(timeLayout);
- // Log level selection controls
- levelGroupBox = new QGroupBox(tr("日志等级"), this);
- QHBoxLayout* levelLayout = new QHBoxLayout();
- levelGroupBox->setLayout(levelLayout);
-
- // Create log level checkboxes with sorted order
- QStringList levels = {"DEBUG", "ERROR", "INFO", "WARN"};
- levels.sort(Qt::CaseInsensitive); // Sort alphabetically
-
- for (const QString& level : levels) {
- QCheckBox* checkBox = new QCheckBox(level, this);
- checkBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
- levelCheckBoxes.append(checkBox);
- levelLayout->addWidget(checkBox);
- }
-
- // Add flexible space for left alignment
- levelLayout->addStretch();
-
- // Module selection controls
- moduleLayout = new QHBoxLayout();
-
- // Add "Select All" and "Deselect All" buttons for module selection
- selectAllModulesButton = new QPushButton(tr("全选"), this);
- deselectAllModulesButton = new QPushButton(tr("全不选"), this);
- connect(selectAllModulesButton, &QPushButton::clicked, this,
- &LogViewer::selectAllModules);
- connect(deselectAllModulesButton, &QPushButton::clicked, this,
- &LogViewer::deselectAllModules);
-
- QHBoxLayout* moduleButtonLayout = new QHBoxLayout();
- moduleButtonLayout->addWidget(selectAllModulesButton);
- moduleButtonLayout->addWidget(deselectAllModulesButton);
- moduleButtonLayout->addStretch();
-
- // Add scroll area to support cases with many modules
- QScrollArea* moduleScrollArea = new QScrollArea(this);
- QWidget* moduleContainer = new QWidget(this);
- moduleLayout->setContentsMargins(5, 5, 5, 5);
- moduleContainer->setLayout(moduleLayout);
- moduleScrollArea->setWidgetResizable(true);
- moduleScrollArea->setWidget(moduleContainer);
-
- // Configure horizontal scrolling for module selection
- moduleScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
- moduleScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-
- // Set fixed height to prevent vertical scrollbar
- moduleScrollArea->setFixedHeight(60); // Adjust height as needed
-
- moduleGroupBox = new QGroupBox(tr("模块选择"), this);
- QVBoxLayout* moduleGroupLayout = new QVBoxLayout();
- moduleGroupLayout->addLayout(moduleButtonLayout); // Add button layout
- moduleGroupLayout->addWidget(moduleScrollArea);
- moduleGroupBox->setLayout(moduleGroupLayout);
-
// Encoding selection
encodingLabel = new QLabel(tr("文件编码:"), this);
encodingComboBox = new QComboBox(this);
@@ -281,8 +230,11 @@ void LogViewer::setupUI()
// Combine filter area
QVBoxLayout* filterAreaLayout = new QVBoxLayout();
filterAreaLayout->addWidget(timeGroupBox);
- filterAreaLayout->addWidget(levelGroupBox);
- filterAreaLayout->addWidget(moduleGroupBox);
+
+ // Dynamic field filters container (populated by rebuildFieldFilterUI)
+ fieldFiltersLayout = new QVBoxLayout();
+
+ filterAreaLayout->addLayout(fieldFiltersLayout);
filterAreaLayout->addLayout(encodingLayout);
filterAreaLayout->addLayout(filterLayout);
filterWidget->setLayout(filterAreaLayout);
@@ -391,6 +343,40 @@ void LogViewer::setupUI()
connect(formatTemplateAction, &QAction::triggered, this,
&LogViewer::onFormatTemplateAction);
+ // Format mode combo
+ formatModeLabel = new QLabel(tr("格式模式:"), this);
+ formatModeCombo = new QComboBox(this);
+ formatModeCombo->addItem(tr("自动识别")); // index 0
+ const auto presetList = LogFormatTemplate::presets();
+ for (const auto& p : presetList) {
+ formatModeCombo->addItem(p.name); // index 1..N
+ }
+ formatModeCombo->addItem(tr("自定义...")); // last index
+ // Restore saved mode
+ int savedMode = AppSettings::instance().getFormatMode();
+ if (savedMode >= 0 && savedMode < formatModeCombo->count() - 1) {
+ formatModeCombo->setCurrentIndex(savedMode);
+ } else if (savedMode < 0) {
+ formatModeCombo->setCurrentIndex(formatModeCombo->count() - 1);
+ }
+ connect(formatModeCombo,
+ QOverload::of(&QComboBox::currentIndexChanged), this,
+ &LogViewer::onFormatModeChanged);
+
+ toolBar->addWidget(formatModeLabel);
+ toolBar->addWidget(formatModeCombo);
+
+ // Hide unmatched lines checkbox
+ hideUnmatchedCheckBox = new QCheckBox(tr("显示不匹配行"), this);
+ hideUnmatchedCheckBox->setChecked(!AppSettings::instance().getHideUnmatched());
+ connect(hideUnmatchedCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
+ bool hide = !checked;
+ AppSettings::instance().setHideUnmatched(hide);
+ if (proxyModel)
+ proxyModel->setHideUnmatched(hide);
+ });
+ toolBar->addWidget(hideUnmatchedCheckBox);
+
// Add separator
toolBar->addSeparator();
@@ -424,6 +410,14 @@ void LogViewer::setupUI()
toolBar->addWidget(languageComboBox);
// Status bar
+ templateInfoLabel = new QLabel(this);
+ templateInfoLabel->setVisible(false);
+ templateInfoLabel->setStyleSheet(
+ "color: #555; padding: 2px 8px; font-size: 9pt;");
+ templateInfoLabel->setWordWrap(false);
+ templateInfoLabel->setMaximumWidth(600);
+ statusBar()->addPermanentWidget(templateInfoLabel);
+
progressBar = new QProgressBar(this);
progressBar->setVisible(false);
statusBar()->addPermanentWidget(progressBar);
@@ -455,8 +449,10 @@ void LogViewer::setupUI()
// Initialize models
sourceModel = new LogTableModel(this);
proxyModel = new LogFilterProxyModel(this);
+ proxyModel->setHideUnmatched(AppSettings::instance().getHideUnmatched());
proxyModel->setSourceModel(sourceModel);
logTreeView->setModel(proxyModel);
+ logTreeView->header()->setStretchLastSection(true);
// Delegate for highlight on content column
highlightDelegate = new HighlightDelegate(this);
logTreeView->setItemDelegateForColumn(LogTableModel::ColumnMessage,
@@ -478,11 +474,30 @@ void LogViewer::openLogFile()
}
}
-void LogViewer::loadLogFile(const QString& filePath)
+void LogViewer::loadLogFile(const QString& filePath,
+ const QString& formatTemplate)
{
currentFilePath = filePath;
QString encoding = encodingComboBox->currentText();
+ // Determine effective template: explicit > formatMode setting
+ QString effectiveTemplate = formatTemplate;
+ if (effectiveTemplate.isEmpty()) {
+ int mode = AppSettings::instance().getFormatMode();
+ if (mode > 0) {
+ // Preset mode: get preset template by index (1-based)
+ const auto presets = LogFormatTemplate::presets();
+ int presetIdx = mode - 1;
+ if (presetIdx >= 0 && presetIdx < presets.size()) {
+ effectiveTemplate = presets[presetIdx].templateStr;
+ }
+ } else if (mode < 0) {
+ // Custom mode: use saved custom template
+ effectiveTemplate = AppSettings::instance().getLogFormatTemplate();
+ }
+ // mode == 0: auto-detect (pass empty)
+ }
+
// Show progress bar
progressBar->setVisible(true);
progressBar->setRange(0, 100);
@@ -490,11 +505,24 @@ void LogViewer::loadLogFile(const QString& filePath)
// Clear previous data
sourceModel->clear();
+ // Reset dynamic columns from previous file
+ sourceModel->setExtraColumns(QStringList());
+ pendingExtraColumns.clear();
+ pendingExtraFieldValues.clear();
+ // Clear extra field filter UI
+ QLayoutItem* child;
+ while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) {
+ QWidget* widget = child->widget();
+ if (widget)
+ widget->deleteLater();
+ delete child;
+ }
+ fieldCheckBoxes.clear();
- // Background loader
+ // Background loader — auto-detect format by default.
+ // A saved template is only used when explicitly passed (e.g. from format dialog reload).
QThread* thread = new QThread(this);
- QString formatTemplate = AppSettings::instance().getLogFormatTemplate();
- LogLoader* loader = new LogLoader(filePath, encoding, 5000, formatTemplate);
+ LogLoader* loader = new LogLoader(filePath, encoding, 5000, effectiveTemplate);
loader->moveToThread(thread);
connect(thread, &QThread::started, loader, &LogLoader::process);
@@ -503,11 +531,11 @@ void LogViewer::loadLogFile(const QString& filePath)
connect(loader, &LogLoader::error, this, [this](const QString& msg) {
QMessageBox::warning(this, tr("错误"), msg);
});
- // 发布构建下,加载期间冻结视图更新以减少重绘
-#ifdef NDEBUG
+ connect(loader, &LogLoader::detectInfoReady, this,
+ &LogViewer::updateTemplateInfo);
+ // 加载期间冻结视图更新以减少重绘
logTreeView->setUpdatesEnabled(false);
logTreeView->viewport()->setUpdatesEnabled(false);
-#endif
connect(loader, &LogLoader::chunkReady, this,
[this](QVector chunk) {
@@ -515,41 +543,54 @@ void LogViewer::loadLogFile(const QString& filePath)
});
connect(loader, &LogLoader::summaryReady, this,
[this](const QDateTime& minTime, const QDateTime& maxTime,
- const QStringList& modules, const QStringList& levels) {
+ const QStringList& modules, const QStringList& levels,
+ const QStringList& extraFieldNames,
+ const QMap& extraFieldValues) {
startTimeEdit->setDateTime(minTime);
endTimeEdit->setDateTime(maxTime);
- allModules = modules;
- allLevels = levels;
- // Rebuild module checkboxes UI
- QLayoutItem* child;
- while ((child = moduleLayout->takeAt(0)) != nullptr) {
- QWidget* widget = child->widget();
- if (widget)
- widget->deleteLater();
- delete child;
+
+ // Merge level, module, and extra fields into unified field data
+ // (level/module are already fixed columns, only add to filter values)
+ QStringList allFieldNames = extraFieldNames;
+ QMap allFieldValues = extraFieldValues;
+
+ if (!levels.isEmpty()) {
+ allFieldValues[QStringLiteral("level")] = levels;
}
- moduleCheckBoxes.clear();
- for (const QString& module : allModules) {
- QCheckBox* checkBox = new QCheckBox(module, this);
- checkBox->setSizePolicy(QSizePolicy::Preferred,
- QSizePolicy::Preferred);
- moduleCheckBoxes.append(checkBox);
- moduleLayout->addWidget(checkBox);
+ if (!modules.isEmpty()) {
+ allFieldValues[QStringLiteral("module")] = modules;
}
- moduleLayout->addStretch();
- for (QCheckBox* checkBox : levelCheckBoxes)
- checkBox->setChecked(true);
- for (QCheckBox* checkBox : moduleCheckBoxes)
- checkBox->setChecked(true);
+
+ // Store for use in finished handler
+ pendingExtraColumns = allFieldNames;
+ pendingExtraFieldValues = allFieldValues;
});
connect(loader, &LogLoader::finished, this,
[this, loader, thread, filePath]() {
- // 发布构建下,加载结束后恢复视图更新并进行一次性刷新
-#ifdef NDEBUG
+ // 恢复视图更新并进行一次性刷新
logTreeView->setUpdatesEnabled(true);
logTreeView->viewport()->setUpdatesEnabled(true);
+
+ // Apply extra columns after view is re-enabled (triggers model reset)
+ sourceModel->setExtraColumns(pendingExtraColumns);
+
+ // Build filter field names: level/module (fixed columns) + extra fields
+ QStringList filterFieldNames;
+ if (pendingExtraFieldValues.contains(QStringLiteral("level")))
+ filterFieldNames.append(QStringLiteral("level"));
+ if (pendingExtraFieldValues.contains(QStringLiteral("module")))
+ filterFieldNames.append(QStringLiteral("module"));
+ filterFieldNames.append(pendingExtraColumns);
+ rebuildFieldFilterUI(filterFieldNames, pendingExtraFieldValues);
+
+ // Move message column to rightmost (it has the most content)
+ QHeaderView* hdr = logTreeView->header();
+ int msgVis = hdr->visualIndex(LogTableModel::ColumnMessage);
+ int lastVis = hdr->count() - 1;
+ if (msgVis != lastVis)
+ hdr->moveSection(msgVis, lastVis);
+
logTreeView->viewport()->update();
-#endif
progressBar->setVisible(false);
exportAction->setEnabled(true);
@@ -575,23 +616,22 @@ void LogViewer::onFilterButtonClicked()
QDateTime startTime = startTimeEdit->dateTime();
QDateTime endTime = endTimeEdit->dateTime();
+
+ // Read level and module from unified fieldCheckBoxes
QStringList selectedLevels;
- for (QCheckBox* checkBox : levelCheckBoxes) {
- if (checkBox->isChecked()) {
- selectedLevels.append(checkBox->text());
+ if (fieldCheckBoxes.contains(QStringLiteral("level"))) {
+ for (QCheckBox* cb : fieldCheckBoxes[QStringLiteral("level")]) {
+ if (cb->isChecked())
+ selectedLevels.append(cb->text());
}
}
QStringList selectedModules;
- for (QCheckBox* checkBox : moduleCheckBoxes) {
- if (checkBox->isChecked()) {
- selectedModules.append(checkBox->text());
+ if (fieldCheckBoxes.contains(QStringLiteral("module"))) {
+ for (QCheckBox* cb : fieldCheckBoxes[QStringLiteral("module")]) {
+ if (cb->isChecked())
+ selectedModules.append(cb->text());
}
}
- if (selectedLevels.isEmpty() || selectedModules.isEmpty()) {
- QMessageBox::warning(this, tr("错误"),
- tr("请至少选择一个日志等级和模块。"));
- return;
- }
// Show progress bar
progressBar->setVisible(true);
@@ -599,8 +639,29 @@ void LogViewer::onFilterButtonClicked()
if (proxyModel) {
proxyModel->setTimeRange(startTime, endTime);
- proxyModel->setLevels(selectedLevels);
- proxyModel->setModules(selectedModules);
+ // Only activate level/module filter when checkboxes exist for that field
+ if (fieldCheckBoxes.contains(QStringLiteral("level")))
+ proxyModel->setLevels(selectedLevels);
+ if (fieldCheckBoxes.contains(QStringLiteral("module")))
+ proxyModel->setModules(selectedModules);
+
+ // Apply extra field filters (level and module already handled above)
+ proxyModel->clearExtraFieldFilters();
+ for (auto it = fieldCheckBoxes.constBegin();
+ it != fieldCheckBoxes.constEnd(); ++it) {
+ if (it.key() == QStringLiteral("level") ||
+ it.key() == QStringLiteral("module"))
+ continue;
+ QSet accepted;
+ for (QCheckBox* cb : it.value()) {
+ if (cb->isChecked()) {
+ accepted.insert(cb->text());
+ }
+ }
+ if (accepted.size() < it.value().size()) {
+ proxyModel->setExtraFieldFilter(it.key(), accepted);
+ }
+ }
}
// Hide progress bar after filtering is complete
@@ -610,6 +671,87 @@ void LogViewer::onFilterButtonClicked()
.arg(proxyModel ? proxyModel->rowCount() : 0));
}
+void LogViewer::rebuildFieldFilterUI(
+ const QStringList& fieldNames,
+ const QMap& fieldValues)
+{
+ // Clear existing field filter UI
+ QLayoutItem* child;
+ while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) {
+ QWidget* widget = child->widget();
+ if (widget)
+ widget->deleteLater();
+ delete child;
+ }
+ fieldCheckBoxes.clear();
+
+ // Create a GroupBox for each field
+ for (const QString& fieldName : fieldNames) {
+ QStringList values = fieldValues.value(fieldName);
+ if (values.isEmpty())
+ continue;
+
+ QGroupBox* groupBox = new QGroupBox(fieldName, this);
+ QVBoxLayout* groupLayout = new QVBoxLayout();
+
+ // Select All / Deselect All buttons
+ QHBoxLayout* buttonLayout = new QHBoxLayout();
+ QPushButton* selectAllBtn = new QPushButton(tr("全选"), this);
+ QPushButton* deselectAllBtn = new QPushButton(tr("取消全选"), this);
+ selectAllBtn->setFixedHeight(24);
+ deselectAllBtn->setFixedHeight(24);
+ buttonLayout->addWidget(selectAllBtn);
+ buttonLayout->addWidget(deselectAllBtn);
+ buttonLayout->addStretch();
+ groupLayout->addLayout(buttonLayout);
+
+ // For fields with many values, use a scroll area
+ bool useScrollArea = (values.size() > 8);
+
+ QHBoxLayout* checkBoxLayout = new QHBoxLayout();
+ QList checkBoxes;
+
+ for (const QString& value : values) {
+ QCheckBox* cb = new QCheckBox(value, this);
+ cb->setChecked(true);
+ cb->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
+ checkBoxes.append(cb);
+ checkBoxLayout->addWidget(cb);
+ }
+ checkBoxLayout->addStretch();
+
+ if (useScrollArea) {
+ QWidget* container = new QWidget(this);
+ container->setLayout(checkBoxLayout);
+ QScrollArea* scrollArea = new QScrollArea(this);
+ scrollArea->setWidgetResizable(true);
+ scrollArea->setWidget(container);
+ scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ scrollArea->setFixedHeight(60);
+ groupLayout->addWidget(scrollArea);
+ } else {
+ groupLayout->addLayout(checkBoxLayout);
+ }
+
+ // Connect select all / deselect all buttons
+ connect(selectAllBtn, &QPushButton::clicked, this,
+ [checkBoxes]() {
+ for (QCheckBox* cb : checkBoxes)
+ cb->setChecked(true);
+ });
+ connect(deselectAllBtn, &QPushButton::clicked, this,
+ [checkBoxes]() {
+ for (QCheckBox* cb : checkBoxes)
+ cb->setChecked(false);
+ });
+
+ groupBox->setLayout(groupLayout);
+ fieldFiltersLayout->addWidget(groupBox);
+ fieldCheckBoxes[fieldName] = checkBoxes;
+ }
+}
+
void LogViewer::toggleFilterArea()
{
if (filterWidget->isVisible()) {
@@ -621,20 +763,6 @@ void LogViewer::toggleFilterArea()
}
}
-void LogViewer::selectAllModules()
-{
- for (QCheckBox* checkBox : moduleCheckBoxes) {
- checkBox->setChecked(true);
- }
-}
-
-void LogViewer::deselectAllModules()
-{
- for (QCheckBox* checkBox : moduleCheckBoxes) {
- checkBox->setChecked(false);
- }
-}
-
void LogViewer::onSearchTextChanged(const QString& text)
{
currentSearchText = text;
@@ -903,18 +1031,57 @@ void LogViewer::onFormatTemplateAction()
if (dialog.exec() == QDialog::Accepted) {
QString newTemplate = dialog.getTemplate();
AppSettings::instance().setLogFormatTemplate(newTemplate);
+ // Switch combo to "Custom" (last item)
+ formatModeCombo->blockSignals(true);
+ formatModeCombo->setCurrentIndex(formatModeCombo->count() - 1);
+ formatModeCombo->blockSignals(false);
+ AppSettings::instance().setFormatMode(-1);
// If a file is already loaded, offer to reload with new format
if (!currentFilePath.isEmpty()) {
QMessageBox::StandardButton reply = QMessageBox::question(
this, tr("Reload"),
tr("Format changed. Reload the current file with the new format?"));
if (reply == QMessageBox::Yes) {
- loadLogFile(currentFilePath);
+ loadLogFile(currentFilePath, newTemplate);
}
}
}
}
+void LogViewer::onFormatModeChanged(int index)
+{
+ int lastIndex = formatModeCombo->count() - 1;
+ if (index == lastIndex) {
+ // "Custom..." selected — open format template dialog
+ AppSettings::instance().setFormatMode(-1);
+ onFormatTemplateAction();
+ return;
+ }
+
+ // Save mode: 0=auto, 1+=preset index
+ AppSettings::instance().setFormatMode(index);
+
+ // Reload current file with new mode if a file is loaded
+ if (!currentFilePath.isEmpty()) {
+ loadLogFile(currentFilePath);
+ }
+}
+
+void LogViewer::updateTemplateInfo(const QString& tmpl, const QString& reason)
+{
+ if (tmpl.isEmpty()) {
+ templateInfoLabel->setVisible(false);
+ return;
+ }
+ QString text = tr("模板: %1").arg(tmpl);
+ if (!reason.isEmpty()) {
+ text += QStringLiteral(" | %1").arg(reason);
+ }
+ templateInfoLabel->setText(text);
+ templateInfoLabel->setVisible(true);
+ templateInfoLabel->setToolTip(text);
+}
+
QList LogViewer::getCurrentFilteredLogs() const
{
if (!proxyModel || !sourceModel) {
@@ -1035,28 +1202,7 @@ void LogViewer::retranslateUI()
<< timeGroupBox->title();
#endif
}
- if (levelGroupBox) {
- QString oldTitle = levelGroupBox->title();
- levelGroupBox->setTitle(tr("日志等级"));
-#ifdef LOG_DEBUG_ENABLED
- qDebug() << "Level group title changed from" << oldTitle << "to"
- << levelGroupBox->title();
-#endif
- }
- if (moduleGroupBox) {
- QString oldTitle = moduleGroupBox->title();
- moduleGroupBox->setTitle(tr("模块选择"));
-#ifdef LOG_DEBUG_ENABLED
- qDebug() << "Module group title changed from" << oldTitle << "to"
- << moduleGroupBox->title();
-#endif
- }
-
// Re-set button text
- if (selectAllModulesButton)
- selectAllModulesButton->setText(tr("全选"));
- if (deselectAllModulesButton)
- deselectAllModulesButton->setText(tr("全不选"));
if (searchPreviousButton)
searchPreviousButton->setText(tr("上一条"));
if (searchNextButton)
@@ -1077,6 +1223,26 @@ void LogViewer::retranslateUI()
encodingLabel->setText(tr("文件编码:"));
if (languageLabel)
languageLabel->setText(tr("语言:"));
+ if (formatModeLabel)
+ formatModeLabel->setText(tr("格式模式:"));
+ if (hideUnmatchedCheckBox)
+ hideUnmatchedCheckBox->setText(tr("显示不匹配行"));
+ // Update format mode combo items (presets may have translations)
+ if (formatModeCombo) {
+ formatModeCombo->blockSignals(true);
+ int savedIndex = formatModeCombo->currentIndex();
+ formatModeCombo->clear();
+ formatModeCombo->addItem(tr("自动识别"));
+ const auto presetList = LogFormatTemplate::presets();
+ for (const auto& p : presetList) {
+ formatModeCombo->addItem(p.name);
+ }
+ formatModeCombo->addItem(tr("自定义..."));
+ if (savedIndex >= 0 && savedIndex < formatModeCombo->count()) {
+ formatModeCombo->setCurrentIndex(savedIndex);
+ }
+ formatModeCombo->blockSignals(false);
+ }
// Re-set table headers (via view header, since we use custom model)
if (logTreeView && logTreeView->header()) {
diff --git a/src/ui/logviewer.h b/src/ui/logviewer.h
index 354c4dd..ceefc97 100644
--- a/src/ui/logviewer.h
+++ b/src/ui/logviewer.h
@@ -17,6 +17,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -126,20 +127,6 @@ private slots:
*/
void toggleFilterArea();
- /**
- * @brief Select all module checkboxes
- * @details Convenience function to quickly select all available log modules
- * for filtering. Useful when user wants to see all modules.
- */
- void selectAllModules();
-
- /**
- * @brief Deselect all module checkboxes
- * @details Convenience function to quickly clear all module selections.
- * Useful for starting fresh with module filtering.
- */
- void deselectAllModules();
-
// Search operations
/**
* @brief Handle search text changes
@@ -198,6 +185,7 @@ private slots:
// Format template operations
void onFormatTemplateAction();
+ void onFormatModeChanged(int index);
// Language operations
/**
@@ -233,7 +221,8 @@ private slots:
* extracts unique modules and levels, and displays results in tree
* view.
*/
- void loadLogFile(const QString& filePath);
+ void loadLogFile(const QString& filePath,
+ const QString& formatTemplate = QString());
/**
* @brief Set up the user interface
@@ -257,6 +246,9 @@ private slots:
*/
void highlightSearchMatches();
void flushSearchDebounce();
+ void rebuildFieldFilterUI(const QStringList& fieldNames,
+ const QMap& fieldValues);
+ void updateTemplateInfo(const QString& tmpl, const QString& reason);
/**
* @brief Expand tree view to show specified item
@@ -286,8 +278,6 @@ private slots:
QDateTimeEdit*
startTimeEdit; ///< Start time selector for time range filtering
QDateTimeEdit* endTimeEdit; ///< End time selector for time range filtering
- QList levelCheckBoxes; ///< Checkboxes for log level selection
- QList moduleCheckBoxes; ///< Checkboxes for module selection
QComboBox* encodingComboBox; ///< Dropdown for file encoding selection
QComboBox* languageComboBox; ///< Dropdown for language selection
@@ -301,9 +291,8 @@ private slots:
// UI Controls - Layout containers
QGroupBox* timeGroupBox; ///< Container for time range controls
- QGroupBox* levelGroupBox; ///< Container for level selection controls
- QGroupBox* moduleGroupBox; ///< Container for module selection controls
- QHBoxLayout* moduleLayout; ///< Layout for module checkboxes
+ QVBoxLayout* fieldFiltersLayout; ///< Layout for dynamic field filter groups
+ QMap> fieldCheckBoxes; ///< All field checkboxes (level, module, extras)
QProgressBar* progressBar; ///< Progress indicator for long operations
QWidget* filterWidget; ///< Container for all filter controls
QSplitter* mainSplitter; ///< Splitter between filter area and main view
@@ -314,8 +303,9 @@ private slots:
QAction* toggleFilterAction; ///< Action to toggle filter area visibility
QAction* exportAction; ///< Export action for menu and toolbar
QAction* formatTemplateAction; ///< Format template action for toolbar
- QPushButton* selectAllModulesButton; ///< Button to select all modules
- QPushButton* deselectAllModulesButton; ///< Button to deselect all modules
+ QComboBox* formatModeCombo; ///< Format mode selector (auto/preset/custom)
+ QLabel* formatModeLabel; ///< Label for format mode combo
+ QCheckBox* hideUnmatchedCheckBox; ///< Toggle to show/hide unmatched lines
// UI Controls - Search
QLineEdit* searchLineEdit; ///< Text input for search terms
@@ -336,9 +326,12 @@ private slots:
// UI Controls - GitHub link
QLabel* githubLinkLabel; ///< Clickable GitHub link in status bar
+ // UI Controls - Template info
+ QLabel* templateInfoLabel; ///< Template info label in status bar
+
// Data storage
- QStringList allModules; ///< List of all unique modules found in logs
- QStringList allLevels; ///< List of all unique log levels found
+ QStringList pendingExtraColumns; ///< Extra column names from last summaryReady
+ QMap pendingExtraFieldValues; ///< Extra field values from last summaryReady
QString currentFilePath; ///< Path of currently loaded log file
QString currentSearchText; ///< Current search term
QVector searchResults; ///< Row indices in proxy model matching search
diff --git a/src/utils/appsettings.cpp b/src/utils/appsettings.cpp
index f12bdb5..534b79f 100644
--- a/src/utils/appsettings.cpp
+++ b/src/utils/appsettings.cpp
@@ -24,6 +24,8 @@ const QString AppSettings::KEY_INCLUDE_MODULE = "export/includeModule";
const QString AppSettings::KEY_INCLUDE_CONTENT = "export/includeContent";
const QString AppSettings::KEY_LANGUAGE = "ui/language";
const QString AppSettings::KEY_LOG_FORMAT_TEMPLATE = "log/formatTemplate";
+const QString AppSettings::KEY_FORMAT_MODE = "log/formatMode";
+const QString AppSettings::KEY_HIDE_UNMATCHED = "log/hideUnmatched";
AppSettings& AppSettings::instance()
{
@@ -140,4 +142,26 @@ QString AppSettings::getLogFormatTemplate() const
{
// 空字符串表示自动识别
return settings->value(KEY_LOG_FORMAT_TEMPLATE, QString()).toString();
+}
+
+void AppSettings::setFormatMode(int mode)
+{
+ settings->setValue(KEY_FORMAT_MODE, mode);
+}
+
+int AppSettings::getFormatMode() const
+{
+ // 0 = 自动识别 (默认)
+ return settings->value(KEY_FORMAT_MODE, 0).toInt();
+}
+
+void AppSettings::setHideUnmatched(bool hide)
+{
+ settings->setValue(KEY_HIDE_UNMATCHED, hide);
+}
+
+bool AppSettings::getHideUnmatched() const
+{
+ // 默认显示所有行(不隐藏)
+ return settings->value(KEY_HIDE_UNMATCHED, false).toBool();
}
\ No newline at end of file
diff --git a/src/utils/appsettings.h b/src/utils/appsettings.h
index 3e89516..e6cac32 100644
--- a/src/utils/appsettings.h
+++ b/src/utils/appsettings.h
@@ -89,6 +89,14 @@ class AppSettings
void setLogFormatTemplate(const QString& formatTemplate);
QString getLogFormatTemplate() const;
+ // 格式模式:0=自动识别, 1+=预设索引, -1=自定义
+ void setFormatMode(int mode);
+ int getFormatMode() const;
+
+ // 隐藏不匹配格式的行
+ void setHideUnmatched(bool hide);
+ bool getHideUnmatched() const;
+
private:
/**
* @brief Private constructor for singleton pattern
@@ -121,6 +129,8 @@ class AppSettings
static const QString
KEY_LANGUAGE; ///< Configuration key for language preference
static const QString KEY_LOG_FORMAT_TEMPLATE;
+ static const QString KEY_FORMAT_MODE;
+ static const QString KEY_HIDE_UNMATCHED;
};
#endif // APPSETTINGS_H
\ No newline at end of file
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 856d439..0fa490c 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -61,6 +61,43 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_logentry.cpp")
set(TEST_TARGETS test_logentry)
endif()
+# 格式检测测试(不需要 QtTest,使用普通 main)
+if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_format_detect.cpp")
+ add_executable(test_format_detect test_format_detect.cpp
+ ${CMAKE_SOURCE_DIR}/src/core/logformattemplate.cpp
+ )
+ if(Qt6_FOUND)
+ target_link_libraries(test_format_detect Qt6::Core)
+ else()
+ target_link_libraries(test_format_detect Qt5::Core)
+ endif()
+ target_include_directories(test_format_detect PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/core
+ )
+ add_test(NAME test_format_detect COMMAND test_format_detect)
+ set_tests_properties(test_format_detect PROPERTIES TIMEOUT 15)
+ list(APPEND TEST_TARGETS test_format_detect)
+endif()
+
+# 解析诊断工具(独立命令行程序,不做ctest)
+if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_parse_diag.cpp")
+ add_executable(test_parse_diag test_parse_diag.cpp
+ ${CMAKE_SOURCE_DIR}/src/core/logformattemplate.cpp
+ )
+ if(Qt6_FOUND)
+ target_link_libraries(test_parse_diag Qt6::Core)
+ else()
+ target_link_libraries(test_parse_diag Qt5::Core)
+ endif()
+ target_include_directories(test_parse_diag PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/core
+ )
+ # 设置工作目录为项目根目录,方便使用相对路径访问 docs/
+ set_target_properties(test_parse_diag PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools"
+ )
+endif()
+
# 以下测试文件暂未实现,注释掉避免CMake错误
# if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_logexporter.cpp")
# add_logviewer_test(test_logexporter
diff --git a/tests/test_format_detect.cpp b/tests/test_format_detect.cpp
new file mode 100644
index 0000000..f82b0ce
--- /dev/null
+++ b/tests/test_format_detect.cpp
@@ -0,0 +1,220 @@
+#include
+#include "logformattemplate.h"
+
+static int failures = 0;
+
+void check(const char* name, bool condition, const char* detail = "")
+{
+ if (condition) {
+ fprintf(stderr, " PASS: %s\n", name);
+ } else {
+ fprintf(stderr, " FAIL: %s %s\n", name, detail);
+ failures++;
+ }
+}
+
+int main(int argc, char* argv[])
+{
+ QStringList predictLines = {
+ "[2026-04-30 15:59:57.934] [C0 +0000] CYCLE_START warmup cold-start warmup",
+ "[2026-04-30 15:59:57.990] [C1 +0000] START M21 test_1/光盘抓手 plan=0-6000 actual=0 rel=1/4",
+ "[2026-04-30 15:59:57.990] [C1 +0000] START N1 test_1/孵育盘-in plan=0-2000 actual=0 rel=1/4",
+ "[2026-04-30 15:59:58.803] [C1 +0813] END M1 test_1/磁分离盘 plan=0-1000 actual=0-813 startDelay=+0",
+ "[2026-04-30 15:59:58.803] [C1 +0813] EMIT M1 -> S(M1.done) wakes=[-]",
+ "[2026-04-30 16:00:06.994] [C1 +9004] START T1 test_1/孵育盘-out plan=9000-11000 actual=9004 rel=1/4"
+ };
+
+ QStringList defaultLines = {
+ "[2025-06-27 08:36:19.123] [INFO] [ModuleName] : 正常信息日志",
+ "[2025-06-27 08:36:20.456] [ERROR] [Database] : 数据库连接失败",
+ "[2025-06-27 08:36:21.789] [WARN] [Network] : 网络延迟过高",
+ "[2025-06-27 08:36:22.012] [DEBUG] [Parser] : 解析详细信息"
+ };
+
+ QStringList customLines = {
+ "[2025-01-15 08:00:01.123] [INFO] [C1 +0001] START TaskA modA : 初始化系统",
+ "[2025-01-15 08:00:02.456] [DEBUG] [C1 +0002] WAIT TaskB modB : 等待资源分配",
+ "[2025-01-15 08:00:03.789] [INFO] [C2 +0001] START TaskA modA : 开始处理请求",
+ "[2025-01-15 08:00:05.345] [ERROR] [C2 +0002] FAIL TaskB modB : 连接超时"
+ };
+
+ // Test 1: Predict log detection
+ fprintf(stderr, "=== Test 1: Predict log (extra fields) ===\n");
+ {
+ LogFormatTemplate fmt = LogFormatTemplate::detect(predictLines);
+ check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData());
+ check("has field1 extra", fmt.extraFieldNames().contains("field1"));
+
+ int mc = 0;
+ for (const QString& line : predictLines) {
+ if (fmt.regex().match(line).hasMatch()) mc++;
+ }
+ check("all lines match", mc == predictLines.size(),
+ QString("%1/%2").arg(mc).arg(predictLines.size()).toUtf8().constData());
+
+ // Verify field1 captures cycle info
+ QRegularExpressionMatch m = fmt.regex().match(predictLines[1]);
+ if (m.hasMatch()) {
+ int idx = fmt.captureIndex("field1");
+ QString val = (idx >= 0) ? m.captured(idx) : "";
+ check("field1 has value", !val.isEmpty(), val.toUtf8().constData());
+ fprintf(stderr, " field1 = '%s'\n", val.toUtf8().constData());
+ }
+ }
+
+ // Test 2: Default log detection (standard fields)
+ fprintf(stderr, "\n=== Test 2: Default log (standard fields) ===\n");
+ {
+ LogFormatTemplate fmt = LogFormatTemplate::detect(defaultLines);
+ check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData());
+ check("has timestamp", fmt.allFieldNames().contains("timestamp"));
+ check("has level", fmt.allFieldNames().contains("level"));
+ check("has message", fmt.allFieldNames().contains("message"));
+
+ int mc = 0;
+ for (const QString& line : defaultLines) {
+ if (fmt.regex().match(line).hasMatch()) mc++;
+ }
+ check("all lines match", mc == defaultLines.size(),
+ QString("%1/%2").arg(mc).arg(defaultLines.size()).toUtf8().constData());
+ }
+
+ // Test 3: Custom fields log detection
+ fprintf(stderr, "\n=== Test 3: Custom fields log ===\n");
+ {
+ LogFormatTemplate fmt = LogFormatTemplate::detect(customLines);
+ check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData());
+
+ int mc = 0;
+ for (const QString& line : customLines) {
+ if (fmt.regex().match(line).hasMatch()) mc++;
+ }
+ check("all lines match", mc == customLines.size(),
+ QString("%1/%2").arg(mc).arg(customLines.size()).toUtf8().constData());
+
+ fprintf(stderr, " template: %s\n", fmt.templateString().toUtf8().constData());
+ fprintf(stderr, " extra fields: ");
+ for (const QString& f : fmt.extraFieldNames()) fprintf(stderr, "%s ", f.toUtf8().constData());
+ fprintf(stderr, "\n");
+ }
+
+ // Test 4: Task scheduler log (the problematic format)
+ fprintf(stderr, "\n=== Test 4: Task scheduler log ===\n");
+ QStringList schedulerLines = {
+ "[2026-05-06 09:50:34.726] ================================================================================",
+ "[CYCLE 1] BEGIN budget=30000ms activeTests=-",
+ "[modA]",
+ " Task Test Rel Action Plan State WaitFor Emit",
+ " ---- -------- ----- ---------- ----------- ------ ------------------------ ----------------",
+ "[2026-05-06 09:50:34.726] [C1 +0001] WAIT B1 modB plan=0-100 missing=[S(A1.done)]",
+ "[2026-05-06 09:50:34.726] [C1 +0001] START A1 modA plan=0-100 actual=1",
+ "[2026-05-06 09:50:34.726] [C1 +0001] END A1 modA plan=0-100 actual=1-1 startDelay=+1",
+ "[2026-05-06 09:50:34.726] [C1 +0001] READY B1 modB <- S(A1.done) [final]",
+ "[2026-05-06 09:50:34.726] [C1 +0001] EMIT A1 -> S(A1.done) wakes=[B1]",
+ "[2026-05-06 09:50:40.799] [C1 +0000] FAULT fault_task#71 unknown Test fault",
+ "[2026-05-06 09:50:40.800] [C1 +0000] START normal_task#72 unknown plan=0-0 actual=0",
+ "[2026-05-06 09:50:46.504] [C1 +0000] START high_priority#147 unknown plan=0-0 actual=0",
+ "[2026-05-06 09:50:48.283] [C1 +0061] START offset_task#171 unknown plan=50-50 actual=61",
+ };
+ {
+ LogFormatTemplate fmt = LogFormatTemplate::detect(schedulerLines);
+ check("valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData());
+ fprintf(stderr, " template: %s\n", fmt.templateString().toUtf8().constData());
+
+ QRegularExpression tsRe(R"(\[\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?\])");
+ int mc = 0;
+ int unmatchedTimestamped = 0;
+ for (const QString& line : schedulerLines) {
+ if (tsRe.match(line).hasMatch()) {
+ if (fmt.regex().match(line).hasMatch()) {
+ mc++;
+ } else {
+ fprintf(stderr, " UNMATCHED timestamped: %s\n", line.left(80).toUtf8().constData());
+ unmatchedTimestamped++;
+ }
+ }
+ }
+ fprintf(stderr, " matched: %d, unmatched timestamped: %d\n", mc, unmatchedTimestamped);
+
+ // Check that FAULT is recognized as a level
+ check("FAULT in LOG_LEVELS",
+ fmt.allFieldNames().contains("level"),
+ "level field should exist");
+
+ // Verify specific field extraction
+ QRegularExpression regex = fmt.regex();
+ QRegularExpressionMatch m = regex.match(schedulerLines[5]); // WAIT line
+ if (m.hasMatch()) {
+ int lvIdx = fmt.captureIndex("level");
+ int msgIdx = fmt.captureIndex("message");
+ QString level = (lvIdx >= 0) ? m.captured(lvIdx) : "";
+ QString message = (msgIdx >= 0) ? m.captured(msgIdx) : "";
+ fprintf(stderr, " WAIT line: level='%s' message='%s'\n",
+ level.toUtf8().constData(), message.left(60).toUtf8().constData());
+ check("WAIT level", level == "WAIT",
+ QString("got '%1'").arg(level).toUtf8().constData());
+ }
+
+ // Check FAULT line
+ QRegularExpressionMatch fm = regex.match(schedulerLines[10]); // FAULT line
+ if (fm.hasMatch()) {
+ int lvIdx = fmt.captureIndex("level");
+ QString level = (lvIdx >= 0) ? fm.captured(lvIdx) : "";
+ fprintf(stderr, " FAULT line: level='%s'\n", level.toUtf8().constData());
+ check("FAULT matched", true);
+ check("FAULT level", level == "FAULT",
+ QString("got '%1'").arg(level).toUtf8().constData());
+ } else {
+ check("FAULT matched", false, "FAULT line did not match template");
+ }
+
+ // Check EMIT line
+ QRegularExpressionMatch em = regex.match(schedulerLines[9]); // EMIT line
+ if (em.hasMatch()) {
+ int lvIdx = fmt.captureIndex("level");
+ QString level = (lvIdx >= 0) ? em.captured(lvIdx) : "";
+ fprintf(stderr, " EMIT line: level='%s'\n", level.toUtf8().constData());
+ check("EMIT matched", true);
+ } else {
+ check("EMIT matched", false, "EMIT line did not match template");
+ }
+ }
+
+ // Test 5: Flexible spacing - verify template handles multi-space alignment
+ fprintf(stderr, "\n=== Test 5: Flexible spacing ===\n");
+ QStringList spacedLines = {
+ "[2026-05-06 09:50:34.726] [C1 +0001] START A1 modA plan=0-100 actual=1",
+ "[2026-05-06 09:50:34.726] [C1 +0001] END A1 modA plan=0-100 actual=1-1 startDelay=+1",
+ "[2026-05-06 09:50:34.726] [C1 +0001] WAIT B1 modB plan=0-100 missing=[S(A1.done)]",
+ "[2026-05-06 09:50:40.799] [C1 +0000] FAULT fault_task#71 unknown Test fault",
+ };
+ {
+ // Test with a known template
+ LogFormatTemplate fmt("[{timestamp}] [{field1}] {level} {field2} {field3} {message}");
+ check("flex template valid", fmt.isValid(), fmt.errorMessage().toUtf8().constData());
+
+ int mc = 0;
+ for (const QString& line : spacedLines) {
+ QRegularExpressionMatch m = fmt.regex().match(line);
+ if (m.hasMatch()) {
+ mc++;
+ int lvIdx = fmt.captureIndex("level");
+ int f2Idx = fmt.captureIndex("field2");
+ int f3Idx = fmt.captureIndex("field3");
+ int msgIdx = fmt.captureIndex("message");
+ fprintf(stderr, " level='%s' field2='%s' field3='%s' msg='%s'\n",
+ m.captured(lvIdx).toUtf8().constData(),
+ m.captured(f2Idx).toUtf8().constData(),
+ m.captured(f3Idx).toUtf8().constData(),
+ m.captured(msgIdx).left(40).toUtf8().constData());
+ } else {
+ fprintf(stderr, " NO MATCH: %s\n", line.left(60).toUtf8().constData());
+ }
+ }
+ check("all spaced lines match", mc == spacedLines.size(),
+ QString("%1/%2").arg(mc).arg(spacedLines.size()).toUtf8().constData());
+ }
+
+ fprintf(stderr, "\n=== Results: %s ===\n", failures == 0 ? "ALL PASSED" : QString("%1 FAILED").arg(failures).toUtf8().constData());
+ return failures;
+}