From 9d71f898cd6174c69f530dcf94e2047aad5a4000 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Thu, 7 May 2026 14:02:27 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E5=88=97=E7=B3=BB=E7=BB=9F=20-=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=92=8C=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogEntry 添加 extraFields 存储动态字段 - LogFormatTemplate 移除 KNOWN_FIELDS 限制,允许任意占位符 - LogTableModel 支持动态列 (setExtraColumns, ExtraFieldsRole) - LogFilterProxyModel 支持额外字段筛选 (setExtraFieldFilter) - LogLoader 解析时填充 extraFields,扩展 summaryReady 信号 - LogViewer 动态创建额外字段筛选 UI (rebuildExtraFieldUI) - LogExporter CSV/JSON/TXT 导出包含额外字段 --- src/core/logentry.h | 6 +++- src/core/logexporter.cpp | 26 ++++++++++++++ src/core/logformattemplate.cpp | 21 ++++++++--- src/core/logformattemplate.h | 3 ++ src/core/logloader.cpp | 21 ++++++++++- src/core/logloader.h | 5 ++- src/ui/logfilterproxymodel.cpp | 27 ++++++++++++++ src/ui/logfilterproxymodel.h | 4 +++ src/ui/logtablemodel.cpp | 18 +++++++++- src/ui/logtablemodel.h | 9 ++++- src/ui/logviewer.cpp | 64 +++++++++++++++++++++++++++++++++- src/ui/logviewer.h | 5 +++ 12 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/core/logentry.h b/src/core/logentry.h index f85b0bd..b05f801 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,13 @@ struct LogEntry */ QString message; + QMap extraFields; + 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; } }; diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp index ffa79ba..8dd7db9 100644 --- a/src/core/logexporter.cpp +++ b/src/core/logexporter.cpp @@ -272,6 +272,16 @@ bool LogExporter::exportToCsv(const QList& logs, if (config.includeContent) headers << QObject::tr("内容"); + // Collect extra field names from all entries + QStringList extraFieldNames; + if (!logs.isEmpty()) { + for (auto it = logs.first().extraFields.constBegin(); + it != logs.first().extraFields.constEnd(); ++it) { + extraFieldNames.append(it.key()); + headers << it.key(); + } + } + out << headers.join(",") << "\n"; // Write each log entry as a CSV row @@ -359,6 +369,12 @@ bool LogExporter::exportToJson(const QList& logs, 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 }"; if (i % 1000 == 0 || i == logs.size() - 1) @@ -422,6 +438,16 @@ QString LogExporter::formatLogEntry(const LogEntry& entry, fields << content; } + // Add extra fields + for (auto it = entry.extraFields.constBegin(); + it != entry.extraFields.constEnd(); ++it) { + QString value = it.value(); + 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/logformattemplate.cpp b/src/core/logformattemplate.cpp index 53b7972..75300f6 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -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(); @@ -106,11 +122,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); diff --git a/src/core/logformattemplate.h b/src/core/logformattemplate.h index 365799b..abe6d8a 100644 --- a/src/core/logformattemplate.h +++ b/src/core/logformattemplate.h @@ -23,6 +23,9 @@ class LogFormatTemplate { bool isValid() const; QString errorMessage() const; + QStringList allFieldNames() const; + QStringList extraFieldNames() const; + static LogFormatTemplate detect(const QStringList& sampleLines); static QList presets(); static const QString DEFAULT_TEMPLATE; diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp index eff98f8..e0ee5ea 100644 --- a/src/core/logloader.cpp +++ b/src/core/logloader.cpp @@ -83,6 +83,8 @@ void LogLoader::process() QDateTime maxTime; QSet modulesSet; QSet levelsSet; + QStringList extraFieldNames = fmt.extraFieldNames(); + QMap> extraFieldSets; qint64 totalBytes = file.size(); qint64 processedBytes = 0; @@ -120,6 +122,15 @@ void LogLoader::process() entry.message = (msgIdx >= 0) ? match.captured(msgIdx) : QString(); + for (const QString& fieldName : extraFieldNames) { + int idx = fmt.captureIndex(fieldName); + if (idx >= 0) { + QString value = match.captured(idx).trimmed(); + entry.extraFields[fieldName] = value; + extraFieldSets[fieldName].insert(value); + } + } + if (entry.timestamp.isValid()) { if (!hasTime) { minTime = maxTime = entry.timestamp; @@ -160,7 +171,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..c8f1b55 100644 --- a/src/core/logloader.h +++ b/src/core/logloader.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -31,7 +32,9 @@ 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 finished(); void error(const QString& message); diff --git a/src/ui/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp index 181672c..0f0a1f7 100644 --- a/src/ui/logfilterproxymodel.cpp +++ b/src/ui/logfilterproxymodel.cpp @@ -1,5 +1,6 @@ #include "logfilterproxymodel.h" +#include #include #include "logtablemodel.h" @@ -29,6 +30,22 @@ void LogFilterProxyModel::setModules(const QStringList& modules) invalidateFilter(); } +void LogFilterProxyModel::setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues) +{ + if (acceptedValues.isEmpty()) { + m_extraFilters.remove(fieldName); + } else { + m_extraFilters[fieldName] = acceptedValues; + } + invalidateFilter(); +} + +void LogFilterProxyModel::clearExtraFieldFilters() +{ + m_extraFilters.clear(); + invalidateFilter(); +} + bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& source_parent) const { QModelIndex tsIndex = sourceModel()->index(source_row, LogTableModel::ColumnTimestamp, source_parent); @@ -51,6 +68,16 @@ bool LogFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& so if (!m_moduleSet.isEmpty() && !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..71f5a68 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,8 @@ 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(); protected: bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override; @@ -29,6 +32,7 @@ class LogFilterProxyModel : public QSortFilterProxyModel QDateTime m_end; QSet m_levelSet; QSet m_moduleSet; + QMap> m_extraFilters; }; #endif // LOGFILTERPROXYMODEL_H diff --git a/src/ui/logtablemodel.cpp b/src/ui/logtablemodel.cpp index e26c08f..2ba4fd6 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 @@ -47,6 +47,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 +63,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 +84,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 +125,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..3e37d96 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,8 @@ class LogTableModel : public QAbstractTableModel TimestampRole = Qt::UserRole + 1, LevelRole, ModuleRole, - MessageRole + MessageRole, + ExtraFieldsRole }; explicit LogTableModel(QObject* parent = nullptr); @@ -53,8 +55,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..69009a2 100644 --- a/src/ui/logviewer.cpp +++ b/src/ui/logviewer.cpp @@ -283,6 +283,11 @@ void LogViewer::setupUI() filterAreaLayout->addWidget(timeGroupBox); filterAreaLayout->addWidget(levelGroupBox); filterAreaLayout->addWidget(moduleGroupBox); + + // Extra fields filter container (populated dynamically) + extraFieldsLayout = new QVBoxLayout(); + + filterAreaLayout->addLayout(extraFieldsLayout); filterAreaLayout->addLayout(encodingLayout); filterAreaLayout->addLayout(filterLayout); filterWidget->setLayout(filterAreaLayout); @@ -515,7 +520,9 @@ 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; @@ -541,6 +548,10 @@ void LogViewer::loadLogFile(const QString& filePath) checkBox->setChecked(true); for (QCheckBox* checkBox : moduleCheckBoxes) checkBox->setChecked(true); + + // Set extra columns on table model and rebuild extra field UI + sourceModel->setExtraColumns(extraFieldNames); + rebuildExtraFieldUI(extraFieldNames, extraFieldValues); }); connect(loader, &LogLoader::finished, this, [this, loader, thread, filePath]() { @@ -601,6 +612,21 @@ void LogViewer::onFilterButtonClicked() proxyModel->setTimeRange(startTime, endTime); proxyModel->setLevels(selectedLevels); proxyModel->setModules(selectedModules); + + // Apply extra field filters + proxyModel->clearExtraFieldFilters(); + for (auto it = extraFieldCheckBoxes.constBegin(); + it != extraFieldCheckBoxes.constEnd(); ++it) { + QSet accepted; + for (QCheckBox* cb : it.value()) { + if (cb->isChecked()) { + accepted.insert(cb->text()); + } + } + if (!accepted.isEmpty() && accepted.size() < it.value().size()) { + proxyModel->setExtraFieldFilter(it.key(), accepted); + } + } } // Hide progress bar after filtering is complete @@ -610,6 +636,42 @@ void LogViewer::onFilterButtonClicked() .arg(proxyModel ? proxyModel->rowCount() : 0)); } +void LogViewer::rebuildExtraFieldUI( + const QStringList& fieldNames, + const QMap& fieldValues) +{ + // Clear existing extra field UI + QLayoutItem* child; + while ((child = extraFieldsLayout->takeAt(0)) != nullptr) { + QWidget* widget = child->widget(); + if (widget) + widget->deleteLater(); + delete child; + } + extraFieldCheckBoxes.clear(); + + // Create a GroupBox for each extra field + for (const QString& fieldName : fieldNames) { + QGroupBox* groupBox = new QGroupBox(fieldName, this); + QHBoxLayout* layout = new QHBoxLayout(); + + QList checkBoxes; + QStringList values = fieldValues.value(fieldName); + for (const QString& value : values) { + QCheckBox* cb = new QCheckBox(value, this); + cb->setChecked(true); + cb->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + checkBoxes.append(cb); + layout->addWidget(cb); + } + layout->addStretch(); + + groupBox->setLayout(layout); + extraFieldsLayout->addWidget(groupBox); + extraFieldCheckBoxes[fieldName] = checkBoxes; + } +} + void LogViewer::toggleFilterArea() { if (filterWidget->isVisible()) { diff --git a/src/ui/logviewer.h b/src/ui/logviewer.h index 354c4dd..18ce7c4 100644 --- a/src/ui/logviewer.h +++ b/src/ui/logviewer.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -257,6 +258,8 @@ private slots: */ void highlightSearchMatches(); void flushSearchDebounce(); + void rebuildExtraFieldUI(const QStringList& fieldNames, + const QMap& fieldValues); /** * @brief Expand tree view to show specified item @@ -304,6 +307,8 @@ private slots: QGroupBox* levelGroupBox; ///< Container for level selection controls QGroupBox* moduleGroupBox; ///< Container for module selection controls QHBoxLayout* moduleLayout; ///< Layout for module checkboxes + QVBoxLayout* extraFieldsLayout; ///< Layout for extra field filter groups + QMap> extraFieldCheckBoxes; ///< Extra field checkboxes QProgressBar* progressBar; ///< Progress indicator for long operations QWidget* filterWidget; ///< Container for all filter controls QSplitter* mainSplitter; ///< Splitter between filter area and main view From 70f45591599f32dc5dc9cadc326239a1668a5323 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Thu, 7 May 2026 14:15:49 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A3=80=E6=B5=8B=EF=BC=8C=E8=AF=86=E5=88=AB=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=AD=E7=9A=84=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyzeLineStructure 现在会收集时间戳后所有方括号字段 - 区分日志等级字段和普通字段 - 普通方括号字段生成为 {field1}, {field2} 等占位符 - 保持日志等级和模块的检测逻辑 --- src/core/logformattemplate.cpp | 181 ++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 73 deletions(-) diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index 75300f6..01883fa 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -244,86 +244,107 @@ 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 + + // 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; + } + } + + // Analyze structure: collect bracketed fields after timestamp + // and classify them as level or extra fields + struct BracketFieldInfo { + int count = 0; + bool isLevel = false; + }; + QMap bracketFieldMap; // field content -> info + int hasLevelCount = 0; + int hasModuleCount = 0; + int totalAnalyzed = 0; 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; - } - + tsMatch = useBracketedTs ? TS_BRACKETED.match(line) : TS_BARE.match(line); if (!tsMatch.hasMatch()) continue; - int tsStart = tsMatch.capturedStart(); - int tsEnd = tsMatch.capturedEnd(); + totalAnalyzed++; + QString afterTs = line.mid(tsMatch.capturedEnd()).trimmed(); - // Get content after timestamp - QString afterTs = line.mid(tsEnd).trimmed(); + // Collect all bracketed fields after timestamp + QRegularExpression bracketRe(R"(^\s*\[([^\]]+)\])"); + int pos = 0; + bool foundLevel = false; + QString remaining = afterTs; - // Check if there's a second bracketed field (level or cycle info) - QRegularExpression bracketField(R"(^\s*\[([^\]]+)\])"); - QRegularExpressionMatch bracketMatch = bracketField.match(afterTs); + while (pos < remaining.length()) { + QRegularExpressionMatch m = bracketRe.match(remaining, pos); + if (!m.hasMatch()) + break; - QString level; - QString rest; + QString content = m.captured(1).trimmed(); + QString key = content.toUpper(); - 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(" "); + // Check if this looks like a log level + bool isLevel = false; + // Single word that matches known levels + if (!content.contains(' ') && LOG_LEVELS.contains(key)) { + isLevel = true; + } + // Multi-word bracket content is NOT a level (e.g., "C1 +0001") + + if (isLevel && !foundLevel) { + foundLevel = true; + hasLevelCount++; + // Mark this position as level + BracketFieldInfo& info = bracketFieldMap["__LEVEL__"]; + info.count++; + info.isLevel = true; + } else { + // Extra field - use position-based naming + QString fieldKey = QString("field_%1").arg(pos); + bracketFieldMap[fieldKey].count++; } + + 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"]++; - } - } + // If no bracketed level found, check for bare level word + if (!foundLevel) { + QString afterBrackets = remaining.mid(pos).trimmed(); + QStringList words = afterBrackets.split(QRegularExpression("\\s+"), + Qt::SkipEmptyParts); + if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) { + hasLevelCount++; } } - if (!level.isEmpty()) { - fieldPositions["has_level"]++; + // Check for module name in remaining text + QString afterBrackets = remaining.mid(pos).trimmed(); + QStringList words = afterBrackets.split(QRegularExpression("\\s+"), + Qt::SkipEmptyParts); + if (!words.isEmpty()) { + QString firstWord = words[0]; + if (QRegularExpression("^[A-Za-z][A-Za-z0-9_]*$").match(firstWord) + .hasMatch()) { + hasModuleCount++; + } } } - // Build template based on analysis - bool hasLevel = fieldPositions.value("has_level", 0) > analyzeCount / 2; - bool hasModule = fieldPositions.value("has_module", 0) > analyzeCount / 2; + if (totalAnalyzed == 0) + return QString(); - // 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; - } - } + // Build template + bool hasLevel = hasLevelCount > totalAnalyzed / 2; + bool hasModule = hasModuleCount > totalAnalyzed / 2; QString templateStr; if (useBracketedTs) { @@ -332,26 +353,40 @@ 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; - } + // Collect bracketed fields in order from first line + // Re-parse first line to get field order + QRegularExpressionMatch firstTsMatch; + firstTsMatch = useBracketedTs ? TS_BRACKETED.match(logLines[0]) + : TS_BARE.match(logLines[0]); + if (firstTsMatch.hasMatch()) { + QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed(); + QRegularExpression bracketRe(R"(^\s*\[([^\]]+)\])"); + int pos = 0; + bool levelPlaced = false; + int extraFieldIdx = 1; + + while (pos < afterTs.length()) { + QRegularExpressionMatch m = bracketRe.match(afterTs, pos); + if (!m.hasMatch()) + break; + + QString content = m.captured(1).trimmed(); + bool isLevel = !content.contains(' ') && + LOG_LEVELS.contains(content.toUpper()); + + if (isLevel && hasLevel && !levelPlaced) { + templateStr += " [{level}]"; + levelPlaced = true; + } else { + templateStr += QString(" [{field%1}]").arg(extraFieldIdx); + extraFieldIdx++; } + + pos = m.capturedEnd(); } - if (levelInBrackets) { - templateStr += " [{level}]"; - } else { + // If level wasn't in brackets but we detected one + if (hasLevel && !levelPlaced) { templateStr += " {level}"; } } From 01dfd2dc533abc3c7689c731501876faa0385f88 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Thu, 7 May 2026 14:18:23 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E5=B8=A6?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5=E7=9A=84=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=A4=BA=E4=BE=8B=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/example_custom_fields.log | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/example_custom_fields.log 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 : 重新初始化 From 9a41b6a4c80a3b2968b0162b13e9c0f15d3d6654 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Thu, 7 May 2026 14:29:15 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A3=80=E6=B5=8B=E6=97=A0=E6=B3=95=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 bracketRe 中的 ^ 锚点,允许匹配后续方括号字段 - 编译时检测占位符是否在方括号内,使用 [^]]+ 替代 \S+ - 智能分析优先于预设匹配,检测到额外字段时直接使用 --- src/core/logformattemplate.cpp | 72 ++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index 01883fa..c85a835 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -136,7 +136,24 @@ 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 ']' + regexStr += QStringLiteral("([^\\]]+)"); + } else { + regexStr += QStringLiteral("(\\S+)"); + } } i = closeBrace + 1; @@ -170,7 +187,33 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) return LogFormatTemplate(); } - // Step 1: Try preset templates + int threshold = qMax(1, sampleLines.size() / 5); + + // Step 1: Smart analysis first - detect field positions dynamically + // This is preferred because it can detect extra fields + 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) { + // If smart analysis found extra fields, use it directly + if (!fmt.extraFieldNames().isEmpty()) { + return fmt; + } + // Otherwise, store as candidate and try presets too + int smartMatchCount = matchCount; + // Fall through to preset comparison + } + } + } + + // Step 2: Try preset templates const auto presetList = presets(); int bestPresetIndex = -1; int bestPresetCount = 0; @@ -193,13 +236,26 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - int threshold = qMax(1, sampleLines.size() / 5); if (bestPresetIndex >= 0 && bestPresetCount >= threshold) { + // Re-check smart analysis: if it matches equally well and has extra fields, prefer it + if (!smartTemplate.isEmpty()) { + LogFormatTemplate smartFmt(smartTemplate); + if (smartFmt.isValid() && !smartFmt.extraFieldNames().isEmpty()) { + int smartCount = 0; + for (const QString& line : sampleLines) { + if (smartFmt.regex().match(line).hasMatch()) { + smartCount++; + } + } + if (smartCount >= bestPresetCount) { + return smartFmt; + } + } + } return LogFormatTemplate(presetList[bestPresetIndex].templateStr); } - // Step 2: Smart analysis - try to detect field positions dynamically - QString smartTemplate = analyzeLineStructure(sampleLines); + // Step 3: Use smart template even without extra fields if (!smartTemplate.isEmpty()) { LogFormatTemplate fmt(smartTemplate); if (fmt.isValid()) { @@ -215,7 +271,7 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - // Step 3: Fallback - timestamp only + // Step 4: Fallback - timestamp only LogFormatTemplate fallback(QStringLiteral("[{timestamp}] {message}")); int fallbackCount = 0; for (const QString& line : sampleLines) { @@ -279,7 +335,7 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) QString afterTs = line.mid(tsMatch.capturedEnd()).trimmed(); // Collect all bracketed fields after timestamp - QRegularExpression bracketRe(R"(^\s*\[([^\]]+)\])"); + QRegularExpression bracketRe(R"(\s*\[([^\]]+)\])"); int pos = 0; bool foundLevel = false; QString remaining = afterTs; @@ -360,7 +416,7 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) : TS_BARE.match(logLines[0]); if (firstTsMatch.hasMatch()) { QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed(); - QRegularExpression bracketRe(R"(^\s*\[([^\]]+)\])"); + QRegularExpression bracketRe(R"(\s*\[([^\]]+)\])"); int pos = 0; bool levelPlaced = false; int extraFieldIdx = 1; From 3d31fbc21fc9241ad6bba1c3093e22cc5058629a Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Thu, 7 May 2026 15:06:09 +0800 Subject: [PATCH 05/13] fix: address PR review comments for dynamic filter columns - Fix empty extra-field filter: unchecking all checkboxes now correctly produces zero matches instead of being treated as unfiltered - Fix CSV headers: collect extra field names from all entries, not just the first one, to handle heterogeneous data correctly - Fix reset: clear dynamic columns and extra field UI at load start to prevent stale columns from previous file - Fix smart template: remove early return when extra fields found; presets are always compared first and preferred when matching equally well, so standard fields like module get proper names - bracketRe ^ anchor was already fixed in prior commit Co-Authored-By: Claude Opus 4.7 --- src/core/logexporter.cpp | 15 +- src/core/logformattemplate.cpp | 278 ++++++++++++++++----------------- src/ui/logfilterproxymodel.cpp | 6 +- src/ui/logviewer.cpp | 13 +- 4 files changed, 158 insertions(+), 154 deletions(-) diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp index 8dd7db9..2ce6b5f 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 @@ -273,14 +274,16 @@ bool LogExporter::exportToCsv(const QList& logs, headers << QObject::tr("内容"); // Collect extra field names from all entries - QStringList extraFieldNames; - if (!logs.isEmpty()) { - for (auto it = logs.first().extraFields.constBegin(); - it != logs.first().extraFields.constEnd(); ++it) { - extraFieldNames.append(it.key()); - headers << it.key(); + 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"; diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index c85a835..e785fd4 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -150,9 +150,21 @@ void LogFormatTemplate::compile() } if (insideBrackets) { // Inside brackets: match everything except ']' - regexStr += QStringLiteral("([^\\]]+)"); + // 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 { - regexStr += QStringLiteral("(\\S+)"); + // 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(')'); } } @@ -189,26 +201,18 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) int threshold = qMax(1, sampleLines.size() / 5); - // Step 1: Smart analysis first - detect field positions dynamically - // This is preferred because it can detect extra fields + // Step 1: Smart analysis - detect field positions dynamically QString smartTemplate = analyzeLineStructure(sampleLines); + int smartMatchCount = 0; + bool smartHasExtraFields = false; if (!smartTemplate.isEmpty()) { LogFormatTemplate fmt(smartTemplate); if (fmt.isValid()) { - int matchCount = 0; + smartHasExtraFields = !fmt.extraFieldNames().isEmpty(); for (const QString& line : sampleLines) { if (fmt.regex().match(line).hasMatch()) { - matchCount++; - } - } - if (matchCount >= threshold) { - // If smart analysis found extra fields, use it directly - if (!fmt.extraFieldNames().isEmpty()) { - return fmt; + smartMatchCount++; } - // Otherwise, store as candidate and try presets too - int smartMatchCount = matchCount; - // Fall through to preset comparison } } } @@ -236,26 +240,17 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } + // Step 3: Choose between smart analysis and presets + // Prefer presets when they match equally well (they have proper field names) + // Only prefer smart analysis when it has extra fields AND matches significantly better if (bestPresetIndex >= 0 && bestPresetCount >= threshold) { - // Re-check smart analysis: if it matches equally well and has extra fields, prefer it - if (!smartTemplate.isEmpty()) { - LogFormatTemplate smartFmt(smartTemplate); - if (smartFmt.isValid() && !smartFmt.extraFieldNames().isEmpty()) { - int smartCount = 0; - for (const QString& line : sampleLines) { - if (smartFmt.regex().match(line).hasMatch()) { - smartCount++; - } - } - if (smartCount >= bestPresetCount) { - return smartFmt; - } - } + if (smartHasExtraFields && smartMatchCount >= bestPresetCount) { + return LogFormatTemplate(smartTemplate); } return LogFormatTemplate(presetList[bestPresetIndex].templateStr); } - // Step 3: Use smart template even without extra fields + // Step 4: Use smart template even without preset match if (!smartTemplate.isEmpty()) { LogFormatTemplate fmt(smartTemplate); if (fmt.isValid()) { @@ -271,7 +266,7 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - // Step 4: Fallback - timestamp only + // Step 5: Fallback - timestamp only LogFormatTemplate fallback(QStringLiteral("[{timestamp}] {message}")); int fallbackCount = 0; for (const QString& line : sampleLines) { @@ -311,97 +306,103 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) } } - // Analyze structure: collect bracketed fields after timestamp - // and classify them as level or extra fields - struct BracketFieldInfo { - int count = 0; - bool isLevel = false; - }; - QMap bracketFieldMap; // field content -> info - int hasLevelCount = 0; - int hasModuleCount = 0; - int totalAnalyzed = 0; + // 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(); - for (int i = 0; i < analyzeCount; ++i) { - const QString& line = logLines[i]; + QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed(); - // Find timestamp - QRegularExpressionMatch tsMatch; - tsMatch = useBracketedTs ? TS_BRACKETED.match(line) : TS_BARE.match(line); - if (!tsMatch.hasMatch()) - continue; + // Collect bracketed fields + QRegularExpression bracketRe(R"(\[([^\]]+)\])"); + int pos = 0; + struct BracketInfo { QString content; bool isLevel; }; + QList bracketFields; - totalAnalyzed++; - QString afterTs = line.mid(tsMatch.capturedEnd()).trimmed(); + while (pos < afterTs.length()) { + QRegularExpressionMatch m = bracketRe.match(afterTs, pos); + if (!m.hasMatch()) + break; - // Collect all bracketed fields after timestamp - QRegularExpression bracketRe(R"(\s*\[([^\]]+)\])"); - int pos = 0; - bool foundLevel = false; - QString remaining = afterTs; + // 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 - while (pos < remaining.length()) { - QRegularExpressionMatch m = bracketRe.match(remaining, pos); - if (!m.hasMatch()) - break; + QString content = m.captured(1).trimmed(); + bool isLevel = !content.contains(' ') && + LOG_LEVELS.contains(content.toUpper()); + bracketFields.append({content, isLevel}); + pos = m.capturedEnd(); + } - QString content = m.captured(1).trimmed(); - QString key = content.toUpper(); + // Get remaining text after all brackets + QString afterBrackets = afterTs.mid(pos).trimmed(); + QStringList remainingWords = afterBrackets.split(QRegularExpression("\\s+"), + Qt::SkipEmptyParts); - // Check if this looks like a log level - bool isLevel = false; - // Single word that matches known levels - if (!content.contains(' ') && LOG_LEVELS.contains(key)) { - isLevel = true; - } - // Multi-word bracket content is NOT a level (e.g., "C1 +0001") - - if (isLevel && !foundLevel) { - foundLevel = true; - hasLevelCount++; - // Mark this position as level - BracketFieldInfo& info = bracketFieldMap["__LEVEL__"]; - info.count++; - info.isLevel = true; - } else { - // Extra field - use position-based naming - QString fieldKey = QString("field_%1").arg(pos); - bracketFieldMap[fieldKey].count++; - } + // 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; + } + } + + // 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; + } + + // 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; - pos = m.capturedEnd(); + 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++; - // If no bracketed level found, check for bare level word - if (!foundLevel) { - QString afterBrackets = remaining.mid(pos).trimmed(); - QStringList words = afterBrackets.split(QRegularExpression("\\s+"), - Qt::SkipEmptyParts); - if (!words.isEmpty() && LOG_LEVELS.contains(words[0].toUpper())) { - hasLevelCount++; - } + // 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++; } - // Check for module name in remaining text - QString afterBrackets = remaining.mid(pos).trimmed(); - QStringList words = afterBrackets.split(QRegularExpression("\\s+"), - Qt::SkipEmptyParts); - if (!words.isEmpty()) { - QString firstWord = words[0]; - if (QRegularExpression("^[A-Za-z][A-Za-z0-9_]*$").match(firstWord) - .hasMatch()) { - hasModuleCount++; - } + // 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++; } - if (totalAnalyzed == 0) - return QString(); - // Build template - bool hasLevel = hasLevelCount > totalAnalyzed / 2; - bool hasModule = hasModuleCount > totalAnalyzed / 2; - QString templateStr; if (useBracketedTs) { templateStr = "[{timestamp}]"; @@ -409,46 +410,39 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) templateStr = "{timestamp}"; } - // Collect bracketed fields in order from first line - // Re-parse first line to get field order - QRegularExpressionMatch firstTsMatch; - firstTsMatch = useBracketedTs ? TS_BRACKETED.match(logLines[0]) - : TS_BARE.match(logLines[0]); - if (firstTsMatch.hasMatch()) { - QString afterTs = logLines[0].mid(firstTsMatch.capturedEnd()).trimmed(); - QRegularExpression bracketRe(R"(\s*\[([^\]]+)\])"); - int pos = 0; - bool levelPlaced = false; - int extraFieldIdx = 1; - - while (pos < afterTs.length()) { - QRegularExpressionMatch m = bracketRe.match(afterTs, pos); - if (!m.hasMatch()) - break; - - QString content = m.captured(1).trimmed(); - bool isLevel = !content.contains(' ') && - LOG_LEVELS.contains(content.toUpper()); - - if (isLevel && hasLevel && !levelPlaced) { - templateStr += " [{level}]"; - levelPlaced = true; - } else { - templateStr += QString(" [{field%1}]").arg(extraFieldIdx); - extraFieldIdx++; - } - - pos = m.capturedEnd(); + // Add bracketed fields + int extraFieldIdx = 1; + bool levelPlaced = false; + for (const BracketInfo& bi : bracketFields) { + if (bi.isLevel && !levelPlaced) { + templateStr += " [{level}]"; + levelPlaced = true; + } else { + templateStr += QString(" [{field%1}]").arg(extraFieldIdx); + extraFieldIdx++; } + } - // If level wasn't in brackets but we detected one - if (hasLevel && !levelPlaced) { - templateStr += " {level}"; - } + // Add bare level if found consistently and not already placed + if (!levelPlaced && consistentBareLevel > analyzeCount / 2) { + templateStr += " {level}"; + levelPlaced = true; } - if (hasModule) { - templateStr += " {module}"; + // Add remaining words as fields (before key=value part) + if (consistentWordsBeforeKV > analyzeCount / 2) { + int wordIdx = 1; + int startWord = hasBareLevel && levelPlaced ? 1 : 0; // skip level if already placed + for (int wi = startWord; wi < wordsBeforeKV; ++wi) { + templateStr += QString(" {field%1}").arg(extraFieldIdx); + extraFieldIdx++; + } + } else if (!remainingWords.isEmpty()) { + // No key=value detected, check if first word looks like a module + if (remainingWords.size() >= 2) { + // First word might be task, second might be module + templateStr += " {module}"; + } } templateStr += " {message}"; diff --git a/src/ui/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp index 0f0a1f7..54cdb5b 100644 --- a/src/ui/logfilterproxymodel.cpp +++ b/src/ui/logfilterproxymodel.cpp @@ -32,11 +32,7 @@ void LogFilterProxyModel::setModules(const QStringList& modules) void LogFilterProxyModel::setExtraFieldFilter(const QString& fieldName, const QSet& acceptedValues) { - if (acceptedValues.isEmpty()) { - m_extraFilters.remove(fieldName); - } else { - m_extraFilters[fieldName] = acceptedValues; - } + m_extraFilters[fieldName] = acceptedValues; invalidateFilter(); } diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp index 69009a2..045e70e 100644 --- a/src/ui/logviewer.cpp +++ b/src/ui/logviewer.cpp @@ -495,6 +495,17 @@ void LogViewer::loadLogFile(const QString& filePath) // Clear previous data sourceModel->clear(); + // Reset dynamic columns from previous file + sourceModel->setExtraColumns(QStringList()); + // Clear extra field filter UI + QLayoutItem* child; + while ((child = extraFieldsLayout->takeAt(0)) != nullptr) { + QWidget* widget = child->widget(); + if (widget) + widget->deleteLater(); + delete child; + } + extraFieldCheckBoxes.clear(); // Background loader QThread* thread = new QThread(this); @@ -623,7 +634,7 @@ void LogViewer::onFilterButtonClicked() accepted.insert(cb->text()); } } - if (!accepted.isEmpty() && accepted.size() < it.value().size()) { + if (accepted.size() < it.value().size()) { proxyModel->setExtraFieldFilter(it.key(), accepted); } } From f4269f9306da9c942a21161204b798c6efd5b8a4 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Fri, 8 May 2026 08:18:00 +0800 Subject: [PATCH 06/13] fix: validate smart template against samples to handle multi-space alignment The analyzeLineStructure function was too aggressive in classifying bare words as separate fields (level, module). For logs with multi-space alignment like "START M21 test_1/path", the generated template "[{timestamp}] [{field1}] {level} {module} {message}" failed because \S+ placeholders can't match through multiple spaces. Now the function validates the detailed template against sample lines. If the simpler base template "[{timestamp}] [{field1}] {message}" matches significantly better, it falls back to that. Also made analyzeLineStructure public for testability and added test_format_detect.cpp to verify detection for predict, default, and custom field log formats. Co-Authored-By: Claude Opus 4.7 --- src/core/logformattemplate.cpp | 31 ++++++++-- src/core/logformattemplate.h | 2 +- tests/CMakeLists.txt | 18 ++++++ tests/test_format_detect.cpp | 103 +++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/test_format_detect.cpp diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index e785fd4..da66623 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -423,7 +423,10 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) } } - // Add bare level if found consistently and not already placed + // 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; @@ -431,22 +434,40 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) // Add remaining words as fields (before key=value part) if (consistentWordsBeforeKV > analyzeCount / 2) { - int wordIdx = 1; - int startWord = hasBareLevel && levelPlaced ? 1 : 0; // skip level if already placed + int startWord = hasBareLevel && levelPlaced ? 1 : 0; for (int wi = startWord; wi < wordsBeforeKV; ++wi) { templateStr += QString(" {field%1}").arg(extraFieldIdx); extraFieldIdx++; } } else if (!remainingWords.isEmpty()) { - // No key=value detected, check if first word looks like a module if (remainingWords.size() >= 2) { - // First word might be task, second might be module templateStr += " {module}"; } } 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 abe6d8a..23dd953 100644 --- a/src/core/logformattemplate.h +++ b/src/core/logformattemplate.h @@ -28,12 +28,12 @@ class LogFormatTemplate { static LogFormatTemplate detect(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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 856d439..2a3b926 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -61,6 +61,24 @@ 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() + # 以下测试文件暂未实现,注释掉避免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..abb86b4 --- /dev/null +++ b/tests/test_format_detect.cpp @@ -0,0 +1,103 @@ +#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"); + } + + fprintf(stderr, "\n=== Results: %s ===\n", failures == 0 ? "ALL PASSED" : QString("%1 FAILED").arg(failures).toUtf8().constData()); + return failures; +} From aae22bbe2e2382831e180321335729a88acaf68a Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 09:34:08 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E6=A1=86UI=EF=BC=8C=E4=BF=AE=E5=A4=8Dlevel/module?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=92=8C=E7=A9=BA=E5=80=BC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个筛选GroupBox添加"全选"/"取消全选"按钮 - 移除自动检测中过于激进的module fallback,避免错误添加{module} - 加载器跳过空的level/module值,防止空白筛选项 - 将level/module从额外列中分离,避免表格列和筛选框重复 Co-Authored-By: Claude Opus 4.7 --- src/core/logformattemplate.cpp | 166 ++++++++++---- src/core/logloader.cpp | 82 ++++--- src/ui/logviewer.cpp | 407 ++++++++++++++++++++------------- 3 files changed, 426 insertions(+), 229 deletions(-) diff --git a/src/core/logformattemplate.cpp b/src/core/logformattemplate.cpp index da66623..06bdd08 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 @@ -99,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]; @@ -108,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; } @@ -168,12 +170,34 @@ void LogFormatTemplate::compile() } } + 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++; } @@ -195,20 +219,28 @@ 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 + // Step 1: Smart analysis - detect field positions dynamically. QString smartTemplate = analyzeLineStructure(sampleLines); int smartMatchCount = 0; - bool smartHasExtraFields = false; + int smartFieldCount = 0; if (!smartTemplate.isEmpty()) { LogFormatTemplate fmt(smartTemplate); if (fmt.isValid()) { - smartHasExtraFields = !fmt.extraFieldNames().isEmpty(); + smartFieldCount = fmt.allFieldNames().size(); for (const QString& line : sampleLines) { if (fmt.regex().match(line).hasMatch()) { smartMatchCount++; @@ -217,7 +249,9 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - // Step 2: 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; @@ -227,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) { @@ -240,54 +281,98 @@ LogFormatTemplate LogFormatTemplate::detect(const QStringList& sampleLines) } } - // Step 3: Choose between smart analysis and presets - // Prefer presets when they match equally well (they have proper field names) - // Only prefer smart analysis when it has extra fields AND matches significantly better - if (bestPresetIndex >= 0 && bestPresetCount >= threshold) { - if (smartHasExtraFields && smartMatchCount >= bestPresetCount) { - return LogFormatTemplate(smartTemplate); + // Step 3: Choose the best template. + if (smartMatchCount >= threshold && smartFieldCount > 0) { + int presetFieldCount = (bestPresetIndex >= 0) + ? LogFormatTemplate(presetList[bestPresetIndex].templateStr).allFieldNames().size() + : 0; + + 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; + } + if (smartFieldCount == presetFieldCount && 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; } - return LogFormatTemplate(presetList[bestPresetIndex].templateStr); + info.templateStr = smartTemplate; + info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行") + .arg(smartFieldCount) + .arg(smartMatchCount) + .arg(totalLines); + return info; } - // Step 4: Use smart template even without preset match - 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; - } - } + // 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 5: 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); } } @@ -435,14 +520,15 @@ QString LogFormatTemplate::analyzeLineStructure(const QStringList& lines) // 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++; } - } else if (!remainingWords.isEmpty()) { - if (remainingWords.size() >= 2) { - templateStr += " {module}"; - } } templateStr += " {message}"; diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp index e0ee5ea..73f9b10 100644 --- a/src/core/logloader.cpp +++ b/src/core/logloader.cpp @@ -62,9 +62,13 @@ void LogLoader::process() sampleLines.append(in.readLine()); } in.seek(startPos); - fmt = LogFormatTemplate::detect(sampleLines); + LogFormatTemplate::DetectInfo detectInfo = + LogFormatTemplate::detectWithInfo(sampleLines); + fmt = LogFormatTemplate(detectInfo.templateStr); + emit detectInfoReady(detectInfo.templateStr, detectInfo.reason); } else { fmt = LogFormatTemplate(m_formatTemplate); + emit detectInfoReady(m_formatTemplate, QString()); } if (!fmt.isValid()) { @@ -78,12 +82,26 @@ 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)); + } + + // Detect timestamp format once + bool tryMsFormat = true; + bool hasTime = false; QDateTime minTime; QDateTime maxTime; QSet modulesSet; QSet levelsSet; - QStringList extraFieldNames = fmt.extraFieldNames(); QMap> extraFieldSets; qint64 totalBytes = file.size(); @@ -100,19 +118,19 @@ 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"); - if (!entry.timestamp.isValid()) { - entry.timestamp = QDateTime::fromString( - match.captured(tsIdx).trimmed(), - "yyyy-MM-dd HH:mm:ss"); + QString tsStr = match.captured(tsIdx).trimmed(); + if (tryMsFormat) { + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss.zzz"); + if (!entry.timestamp.isValid()) { + tryMsFormat = false; + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss"); + } + } else { + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss"); } } entry.level = (lvIdx >= 0) ? match.captured(lvIdx).trimmed() @@ -122,12 +140,12 @@ void LogLoader::process() entry.message = (msgIdx >= 0) ? match.captured(msgIdx) : QString(); - for (const QString& fieldName : extraFieldNames) { - int idx = fmt.captureIndex(fieldName); + for (int ei = 0; ei < extraFieldNames.size(); ++ei) { + int idx = extraIdxList[ei]; if (idx >= 0) { QString value = match.captured(idx).trimmed(); - entry.extraFields[fieldName] = value; - extraFieldSets[fieldName].insert(value); + entry.extraFields[extraFieldNames[ei]] = value; + extraFieldSets[extraFieldNames[ei]].insert(value); } } @@ -142,19 +160,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); } } } diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp index 045e70e..16e1469 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,13 +230,11 @@ void LogViewer::setupUI() // Combine filter area QVBoxLayout* filterAreaLayout = new QVBoxLayout(); filterAreaLayout->addWidget(timeGroupBox); - filterAreaLayout->addWidget(levelGroupBox); - filterAreaLayout->addWidget(moduleGroupBox); - // Extra fields filter container (populated dynamically) - extraFieldsLayout = new QVBoxLayout(); + // Dynamic field filters container (populated by rebuildFieldFilterUI) + fieldFiltersLayout = new QVBoxLayout(); - filterAreaLayout->addLayout(extraFieldsLayout); + filterAreaLayout->addLayout(fieldFiltersLayout); filterAreaLayout->addLayout(encodingLayout); filterAreaLayout->addLayout(filterLayout); filterWidget->setLayout(filterAreaLayout); @@ -396,6 +343,38 @@ 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); + } + 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(); @@ -429,6 +408,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); @@ -460,8 +447,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, @@ -483,11 +472,27 @@ 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; + } + } + // mode == 0: auto-detect (pass empty) + } + // Show progress bar progressBar->setVisible(true); progressBar->setRange(0, 100); @@ -497,20 +502,22 @@ void LogViewer::loadLogFile(const QString& filePath) sourceModel->clear(); // Reset dynamic columns from previous file sourceModel->setExtraColumns(QStringList()); + pendingExtraColumns.clear(); + pendingExtraFieldValues.clear(); // Clear extra field filter UI QLayoutItem* child; - while ((child = extraFieldsLayout->takeAt(0)) != nullptr) { + while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) { QWidget* widget = child->widget(); if (widget) widget->deleteLater(); delete child; } - extraFieldCheckBoxes.clear(); + 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); @@ -519,11 +526,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) { @@ -536,42 +543,49 @@ void LogViewer::loadLogFile(const QString& filePath) 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); - - // Set extra columns on table model and rebuild extra field UI - sourceModel->setExtraColumns(extraFieldNames); - rebuildExtraFieldUI(extraFieldNames, extraFieldValues); + + // 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); @@ -597,23 +611,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); @@ -624,10 +637,13 @@ void LogViewer::onFilterButtonClicked() proxyModel->setLevels(selectedLevels); proxyModel->setModules(selectedModules); - // Apply extra field filters + // Apply extra field filters (level and module already handled above) proxyModel->clearExtraFieldFilters(); - for (auto it = extraFieldCheckBoxes.constBegin(); - it != extraFieldCheckBoxes.constEnd(); ++it) { + 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()) { @@ -647,39 +663,84 @@ void LogViewer::onFilterButtonClicked() .arg(proxyModel ? proxyModel->rowCount() : 0)); } -void LogViewer::rebuildExtraFieldUI( +void LogViewer::rebuildFieldFilterUI( const QStringList& fieldNames, const QMap& fieldValues) { - // Clear existing extra field UI + // Clear existing field filter UI QLayoutItem* child; - while ((child = extraFieldsLayout->takeAt(0)) != nullptr) { + while ((child = fieldFiltersLayout->takeAt(0)) != nullptr) { QWidget* widget = child->widget(); if (widget) widget->deleteLater(); delete child; } - extraFieldCheckBoxes.clear(); + fieldCheckBoxes.clear(); - // Create a GroupBox for each extra field + // Create a GroupBox for each field for (const QString& fieldName : fieldNames) { - QGroupBox* groupBox = new QGroupBox(fieldName, this); - QHBoxLayout* layout = new QHBoxLayout(); + 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; - QStringList values = fieldValues.value(fieldName); + for (const QString& value : values) { QCheckBox* cb = new QCheckBox(value, this); cb->setChecked(true); cb->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); checkBoxes.append(cb); - layout->addWidget(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); } - layout->addStretch(); - groupBox->setLayout(layout); - extraFieldsLayout->addWidget(groupBox); - extraFieldCheckBoxes[fieldName] = checkBoxes; + // 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; } } @@ -694,20 +755,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; @@ -976,18 +1023,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) { @@ -1108,28 +1194,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) @@ -1150,6 +1215,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()) { From 5a93d792749874939bcde3bb2903da23f9e07b39 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 10:24:16 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DPR=20#16=20revie?= =?UTF-8?q?w=20comments=20(CSV=E5=AF=B9=E9=BD=90/=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7/=E9=AB=98=E4=BA=AE=E5=A4=B1?= =?UTF-8?q?=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSV导出: formatLogEntry 接受 extraFieldNames 参数,缺失字段输出空列 - 模板检测: preset 匹配良好时优先使用,保留语义字段名(如 module) - 搜索高亮: HighlightDelegate 改用固定 ColumnMessage 列索引,不再依赖最后一列 Co-Authored-By: Claude Opus 4.7 --- src/core/logexporter.cpp | 86 ++++++++++++++++++++-------------- src/core/logexporter.h | 3 +- src/core/logformattemplate.cpp | 22 +++++---- src/ui/highlightdelegate.cpp | 4 +- 4 files changed, 68 insertions(+), 47 deletions(-) diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp index 2ce6b5f..e87815b 100644 --- a/src/core/logexporter.cpp +++ b/src/core/logexporter.cpp @@ -219,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()); @@ -290,8 +294,13 @@ bool LogExporter::exportToCsv(const QList& logs, // Write each log entry as a CSV row 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: put rawLine in first column, rest empty + out << escapeForCsv(entry.rawLine) << "\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()); @@ -350,32 +359,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); - } - - // Add extra fields - for (auto it = entry.extraFields.constBegin(); - it != entry.extraFields.constEnd(); ++it) { - writeField(it.key(), it.value()); + 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 }"; @@ -405,7 +418,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; @@ -441,10 +455,12 @@ QString LogExporter::formatLogEntry(const LogEntry& entry, fields << content; } - // Add extra fields - for (auto it = entry.extraFields.constBegin(); - it != entry.extraFields.constEnd(); ++it) { - QString value = it.value(); + // 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); } 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 06bdd08..19608b7 100644 --- a/src/core/logformattemplate.cpp +++ b/src/core/logformattemplate.cpp @@ -287,6 +287,19 @@ LogFormatTemplate::DetectInfo LogFormatTemplate::detectWithInfo(const QStringLis ? 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; + } + if (smartFieldCount > presetFieldCount) { info.templateStr = smartTemplate; int extra = smartFieldCount - 4; // subtract known fields @@ -298,15 +311,6 @@ LogFormatTemplate::DetectInfo LogFormatTemplate::detectWithInfo(const QStringLis info.reason += QStringLiteral(", 含%1个自定义字段").arg(extra); return info; } - if (smartFieldCount == presetFieldCount && 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; - } info.templateStr = smartTemplate; info.reason = QStringLiteral("智能检测: %1个字段, 匹配%2/%3行") .arg(smartFieldCount) 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; } From ecdafced64917c24117922a143b8483d68469d08 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 10:28:15 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E6=8F=90=E4=BA=A4=E9=81=97?= =?UTF-8?q?=E6=BC=8F=E7=9A=84=E5=A4=B4=E6=96=87=E4=BB=B6=E5=92=8C=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=94=B9=E5=8A=A8=EF=BC=8C=E4=BF=AE=E5=A4=8DCI?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logentry.h: 添加 rawLine、matched 字段 - logformattemplate.h: 添加 DetectInfo 结构和 detectWithInfo 声明 - 其他文件: 同步未提交的 feature 改动 Co-Authored-By: Claude Opus 4.7 --- resources/resources.qrc | 1 + src/core/logentry.h | 6 +- src/core/logformattemplate.h | 6 ++ src/core/logloader.h | 1 + src/main.cpp | 1 + src/ui/logfilterproxymodel.cpp | 12 ++++ src/ui/logfilterproxymodel.h | 2 + src/ui/logtablemodel.cpp | 15 +++++ src/ui/logtablemodel.h | 3 +- src/ui/logviewer.h | 44 +++++-------- src/utils/appsettings.cpp | 24 +++++++ src/utils/appsettings.h | 10 +++ tests/CMakeLists.txt | 19 ++++++ tests/test_format_detect.cpp | 117 +++++++++++++++++++++++++++++++++ 14 files changed, 231 insertions(+), 30 deletions(-) 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 b05f801..9138376 100644 --- a/src/core/logentry.h +++ b/src/core/logentry.h @@ -78,11 +78,15 @@ struct LogEntry 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 && - extraFields == other.extraFields; + extraFields == other.extraFields && rawLine == other.rawLine && + matched == other.matched; } }; diff --git a/src/core/logformattemplate.h b/src/core/logformattemplate.h index 23dd953..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); @@ -27,6 +32,7 @@ class LogFormatTemplate { 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; diff --git a/src/core/logloader.h b/src/core/logloader.h index c8f1b55..cf8e894 100644 --- a/src/core/logloader.h +++ b/src/core/logloader.h @@ -35,6 +35,7 @@ public slots: 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/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp index 54cdb5b..767d851 100644 --- a/src/ui/logfilterproxymodel.cpp +++ b/src/ui/logfilterproxymodel.cpp @@ -42,8 +42,20 @@ void LogFilterProxyModel::clearExtraFieldFilters() 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); diff --git a/src/ui/logfilterproxymodel.h b/src/ui/logfilterproxymodel.h index 71f5a68..ae0778d 100644 --- a/src/ui/logfilterproxymodel.h +++ b/src/ui/logfilterproxymodel.h @@ -23,6 +23,7 @@ class LogFilterProxyModel : public QSortFilterProxyModel 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; @@ -33,6 +34,7 @@ class LogFilterProxyModel : public QSortFilterProxyModel QSet m_levelSet; QSet m_moduleSet; QMap> m_extraFilters; + bool m_hideUnmatched = false; }; #endif // LOGFILTERPROXYMODEL_H diff --git a/src/ui/logtablemodel.cpp b/src/ui/logtablemodel.cpp index 2ba4fd6..263738d 100644 --- a/src/ui/logtablemodel.cpp +++ b/src/ui/logtablemodel.cpp @@ -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: diff --git a/src/ui/logtablemodel.h b/src/ui/logtablemodel.h index 3e37d96..5cc6724 100644 --- a/src/ui/logtablemodel.h +++ b/src/ui/logtablemodel.h @@ -37,7 +37,8 @@ class LogTableModel : public QAbstractTableModel LevelRole, ModuleRole, MessageRole, - ExtraFieldsRole + ExtraFieldsRole, + MatchedRole }; explicit LogTableModel(QObject* parent = nullptr); diff --git a/src/ui/logviewer.h b/src/ui/logviewer.h index 18ce7c4..ceefc97 100644 --- a/src/ui/logviewer.h +++ b/src/ui/logviewer.h @@ -127,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 @@ -199,6 +185,7 @@ private slots: // Format template operations void onFormatTemplateAction(); + void onFormatModeChanged(int index); // Language operations /** @@ -234,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 @@ -258,8 +246,9 @@ private slots: */ void highlightSearchMatches(); void flushSearchDebounce(); - void rebuildExtraFieldUI(const QStringList& fieldNames, - const QMap& fieldValues); + void rebuildFieldFilterUI(const QStringList& fieldNames, + const QMap& fieldValues); + void updateTemplateInfo(const QString& tmpl, const QString& reason); /** * @brief Expand tree view to show specified item @@ -289,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 @@ -304,11 +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* extraFieldsLayout; ///< Layout for extra field filter groups - QMap> extraFieldCheckBoxes; ///< Extra field 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 @@ -319,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 @@ -341,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 2a3b926..0fa490c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -79,6 +79,25 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_format_detect.cpp") 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 index abb86b4..f82b0ce 100644 --- a/tests/test_format_detect.cpp +++ b/tests/test_format_detect.cpp @@ -98,6 +98,123 @@ int main(int argc, char* argv[]) 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; } From afc9366b0b2e991e7a408148ac3290e9d8e47aa8 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 10:31:02 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E9=81=97?= =?UTF-8?q?=E6=BC=8F=E7=9A=84=20template.png=20=E8=B5=84=E6=BA=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- resources/icons/template.png | Bin 0 -> 1747 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/icons/template.png diff --git a/resources/icons/template.png b/resources/icons/template.png new file mode 100644 index 0000000000000000000000000000000000000000..2417d3632315f65285622b5b98a724e8eb97b3f3 GIT binary patch literal 1747 zcmZuy3pCVeAD)Y07$djAh>R&2bR;sBRDVo0z6qmY5sIEd; z8!PvZutbTY{BS#G|!Gfi#MxLb`$F`+xso;`O=rj7~9Ml@vN|EWAo2Pp~I0MVNzC#{}ug=nv7_mWbDLG zt3F(N3WYLS6EGSf#09yRN1TNH=JySrxO+pm}TV4 z9)K3-exKc|mz|@BkT5BMbN53`&P#cv!rQf!VoyjQC5YF|m5y?BI*Kx_z%gC+l@Yq${9&-Qwz20*$73DKXO}ME*n_>Nd~d$`PJM1!tc`p2u*m2QJY@5c zdho|HX)#wKE`5OC#&Q&3BhxzFvW@CGI{f$fDSY00rZp!?%@dd!*QLE%X{+Rg*4Vj{ zul9Y8gq7kNo?0djP%C)u;|n)k**rmRo{cVTY9;acn2h!a7FzmcuVz!dG!^eVTYtlO zl6(?@C;hqUxdW8(9OY&9PlmUs=)_FK^>Zp&>m7|Q>obc9FOi?ofPi1QB&JRw(}sIV zdvZ$Zd3~9AKBm3ehpWo&tp)r*KccD;7nH6J{PKFF8b;1EKnza|K~R@nQE6v=nYE8~ zInt5f7TK+sWqHJBR@k|>Jzv#S7u={*GeyhVg;$F~9pnQ-&uOM(SGHoNCW0f~YGhc9 z+HGn-qVU{=Bin6$rbe35iIPtpbmmA&(AvnWV)>J;krfbk%cnzm;BOSKIvpP9)$)Oq zvst{{7~1n>B6VOCq_U88#vJx`u1ybUIAMfGk&*>Q+DXC-OBT@)ANV&Tjt)o_U+1Pm z)kD5E>qNJiSVV2X(*$MQ?VU;iKcsPg_`mw5>jPX?>Cw?!4>laSGs+n>y1((v)?y9s z)0wE|36Ybk+mYIKX1Vg-nHduuC*3UYP?-GO1_>&v@SD8Gs9)z#j#3%*(dfr5!HW-4 zA5P{bPi0Q;1h5Jo^V>PFMi8sLF#@85))IvYZ?bpK7NB6b<7!Ymc!R7hqocnq9elSHR4a*MVY6ndtH1Uqsk-oS9y z-ceB2dm=;jIDJ(C(CW)m%s<_(%Q+KFHs~wGv70IH}qzh9X;g%a^@NyEE!En@x%v&kf;sGmhgQ=sv3};P%1+tvf)Nf^ zrzAUhgYl`OWz=JSie2jdvAxzv%OGXeE8v*I&P{0iwu!M&l;XRBE1zv-k=77~7ZcP5 zhVuX2HDrfyyr@_OBd6W1kU8(*sTEstOkf`2LWhb3)ZZL8awbLPr@faJ6DY{)LmQPiZ8#$=2oNH}a+X<$p#yq*TCPkjCyh>x` z`!YrcR~($s1s81Y8UsY_T@TuA-aqCvs$w-bIM`3%a=8Gk292TYEqK#(9|aKiNb39L zfU*MieCYmD@Zzu{Q&bIlH~;q~NO10a3B>$jFm|CeP%jT07QpcIIcOA{zE4t;jsp6& z>+HL7ZTCQKv=la5;i2$ltN-!D^fu(HAN-vg-Q6VQDw Date: Sat, 9 May 2026 10:43:38 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=89=A9?= =?UTF-8?q?=E4=BD=99=20Codex=20review=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 时间戳解析: 每行都尝试两种格式,不再用 tryMsFormat 标志跳过 - 自定义格式: mode==-1 时从 AppSettings 读取自定义模板 - CSV导出: unmatched 行填充空列保持宽度一致 Co-Authored-By: Claude Opus 4.7 --- src/core/logexporter.cpp | 8 ++++++-- src/core/logloader.cpp | 12 ++---------- src/ui/logviewer.cpp | 3 +++ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/core/logexporter.cpp b/src/core/logexporter.cpp index e87815b..8c02f5a 100644 --- a/src/core/logexporter.cpp +++ b/src/core/logexporter.cpp @@ -292,11 +292,15 @@ bool LogExporter::exportToCsv(const QList& logs, 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]; if (!entry.matched) { - // Unmatched line: put rawLine in first column, rest empty - out << escapeForCsv(entry.rawLine) << "\n"; + // 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"; diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp index 73f9b10..ab568e3 100644 --- a/src/core/logloader.cpp +++ b/src/core/logloader.cpp @@ -94,9 +94,6 @@ void LogLoader::process() extraIdxList.append(fmt.captureIndex(fn)); } - // Detect timestamp format once - bool tryMsFormat = true; - bool hasTime = false; QDateTime minTime; QDateTime maxTime; @@ -123,13 +120,8 @@ void LogLoader::process() if (tsIdx >= 0) { QString tsStr = match.captured(tsIdx).trimmed(); - if (tryMsFormat) { - entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss.zzz"); - if (!entry.timestamp.isValid()) { - tryMsFormat = false; - entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss"); - } - } else { + entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss.zzz"); + if (!entry.timestamp.isValid()) { entry.timestamp = QDateTime::fromString(tsStr, "yyyy-MM-dd HH:mm:ss"); } } diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp index 16e1469..2c25b50 100644 --- a/src/ui/logviewer.cpp +++ b/src/ui/logviewer.cpp @@ -489,6 +489,9 @@ void LogViewer::loadLogFile(const QString& filePath, 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) } From 477aaa9045fb28db07d9b934e92ddc246f1b774b Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 10:59:13 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Codex=20?= =?UTF-8?q?=E6=96=B0=E8=AF=84=E8=AE=BA=20(auto-detect=20fallback=20+=20?= =?UTF-8?q?=E7=A9=BA=E9=80=89=E7=AD=9B=E9=80=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动检测返回空模板时回退到默认模板,不再直接报错 - level/module 全部取消选中时正确排除所有行(用 active 标志区分"未筛选"和"全不选") Co-Authored-By: Claude Opus 4.7 --- src/core/logloader.cpp | 4 ++++ src/ui/logfilterproxymodel.cpp | 6 ++++-- src/ui/logfilterproxymodel.h | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/logloader.cpp b/src/core/logloader.cpp index ab568e3..07897ab 100644 --- a/src/core/logloader.cpp +++ b/src/core/logloader.cpp @@ -64,6 +64,10 @@ void LogLoader::process() in.seek(startPos); 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 { diff --git a/src/ui/logfilterproxymodel.cpp b/src/ui/logfilterproxymodel.cpp index 767d851..9bcae50 100644 --- a/src/ui/logfilterproxymodel.cpp +++ b/src/ui/logfilterproxymodel.cpp @@ -21,12 +21,14 @@ 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(); } @@ -70,10 +72,10 @@ 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()) { diff --git a/src/ui/logfilterproxymodel.h b/src/ui/logfilterproxymodel.h index ae0778d..a935980 100644 --- a/src/ui/logfilterproxymodel.h +++ b/src/ui/logfilterproxymodel.h @@ -32,7 +32,9 @@ 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; }; From 9f28af9c2c72f17b1b063787ae890ebc8758bf02 Mon Sep 17 00:00:00 2001 From: GeziP <334223834@qq.com> Date: Sat, 9 May 2026 11:11:17 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=A8=A1=E5=BC=8Fcombo=E6=81=A2=E5=A4=8D=20+?= =?UTF-8?q?=20=E6=97=A0level=E5=AD=97=E6=AE=B5=E6=97=B6=E8=AF=AF=E7=AD=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatMode==-1 时combo选中"自定义..."项 - 只在level/module checkbox存在时才激活对应筛选,避免无该字段的日志被清空 Co-Authored-By: Claude Opus 4.7 --- src/ui/logviewer.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui/logviewer.cpp b/src/ui/logviewer.cpp index 2c25b50..e0c200f 100644 --- a/src/ui/logviewer.cpp +++ b/src/ui/logviewer.cpp @@ -356,6 +356,8 @@ void LogViewer::setupUI() 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, @@ -637,8 +639,11 @@ 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();