优化了xml修改和表格逆生成功能

This commit is contained in:
lnk
2026-07-14 15:14:54 +08:00
parent 70c1534d96
commit 6bfaa32db4
74 changed files with 15935 additions and 1804129 deletions

View File

@@ -14,6 +14,26 @@
#include <unordered_map>
#include <vector>
/*
* icd_mapper.cpp
*
* 功能:把外部 ICD/XML 中 LN/DOI 的 desc 描述补写到工作簿
* “未匹配的原始数据”工作表的“icd中代表的字段”列。
*
* 输入:
* icdPath 外部 ICD/XML 文件,可能是 UTF-8也可能声明/实际使用 GBK/GB2312。
* workbookPath main.cpp 生成的 Excel 2003 XML 格式 .xls。
*
* 输出:
* 直接修改 workbookPath。message 返回解析结果、匹配数量或错误原因。
*
* 核心匹配规则:
* 1. 从 ICD 的每个 LN 节点读取 lnClass + inst并遍历其 DOI 子节点;
* 2. 组成 DO keylnClass + inst + "$MX$" + DOI/@name
* 3. 工作簿未匹配页的“原始路径”如果等于该 key或以 key + "$" 开头,
* 就认为该原始 MMS 路径属于这个 DOI把 DOI/@desc 写入目标列。
*/
namespace fs = std::filesystem;
namespace {
@@ -46,6 +66,7 @@ bool declaresGbEncoding(const std::string& text) {
}
bool isLikelyUtf8(const std::string& text) {
// 只做 UTF-8 字节合法性判断,不判断语义;合法就优先按 UTF-8 给 tinyxml2。
int remaining = 0;
for (unsigned char c : text) {
if (remaining == 0) {
@@ -63,6 +84,7 @@ bool isLikelyUtf8(const std::string& text) {
}
std::string ansiToUtf8(const std::string& text) {
// 936 是 Windows 简体中文代码页,兼容常见 GBK/GB2312 ICD 文件。
if (text.empty()) return "";
int wideLen = MultiByteToWideChar(936, 0, text.data(), (int)text.size(), nullptr, 0);
if (wideLen <= 0) return text;
@@ -76,6 +98,7 @@ std::string ansiToUtf8(const std::string& text) {
std::string loadXmlText(const fs::path& path) {
std::string raw = readAllBinary(path);
// ICD 声明 GB 编码或字节本身不像 UTF-8 时,先转成 UTF-8 再交给 tinyxml2。
if (declaresGbEncoding(raw) || !isLikelyUtf8(raw)) return ansiToUtf8(raw);
return raw;
}
@@ -106,6 +129,7 @@ void collectIcdDescriptions(const tinyxml2::XMLElement* element,
std::string doiName = attr(child, "name");
std::string desc = attr(child, "desc");
if (doiName.empty() || desc.empty()) continue;
// 这里构造的是 origin 原始路径前缀。后面路径可继续带 $mag$f 等 DA 后缀。
std::string key = lnClass + inst + "$MX$" + doiName;
descByDo[key] = desc;
}
@@ -155,6 +179,7 @@ tinyxml2::XMLElement* cellAt(tinyxml2::XMLElement* row, int col) {
tinyxml2::XMLElement* ensureCell(tinyxml2::XMLDocument& doc, tinyxml2::XMLElement* row, int col) {
if (tinyxml2::XMLElement* existing = cellAt(row, col)) return existing;
// Excel XML 允许通过 ss:Index 跳列;补列时直接设置 ss:Index避免改动前面已有单元格。
tinyxml2::XMLElement* cell = doc.NewElement("Cell");
cell->SetAttribute("ss:Index", col);
row->InsertEndChild(cell);
@@ -175,6 +200,7 @@ std::vector<std::string> sortedKeysByLength(const std::unordered_map<std::string
std::vector<std::string> keys;
keys.reserve(values.size());
for (const auto& kv : values) keys.push_back(kv.first);
// 长路径优先,避免短 key 先命中长 key 的前缀。
std::sort(keys.begin(), keys.end(), [](const std::string& a, const std::string& b) {
if (a.size() != b.size()) return a.size() > b.size();
return a < b;
@@ -183,6 +209,7 @@ std::vector<std::string> sortedKeysByLength(const std::unordered_map<std::string
}
bool pathMatchesDo(const std::string& rawPath, const std::string& doKey) {
// rawPath 可以正好是 DO也可以是 DO$DA...;必须在 "$" 边界匹配,避免误命中相似前缀。
return rawPath == doKey ||
(rawPath.size() > doKey.size() &&
rawPath.compare(0, doKey.size(), doKey) == 0 &&
@@ -230,6 +257,7 @@ bool pqApplyIcdDescriptionsToWorkbook(const std::string& icdPath,
const std::string& workbookPath,
std::string* message) {
try {
// 第一步:解析 ICD得到 “DO 路径前缀 -> 中文描述”。
auto descByDo = parseIcdDescriptions(fs::u8path(icdPath));
if (descByDo.empty()) {
if (message) *message = "ICD 中没有解析到 LN/DOI desc";
@@ -237,6 +265,7 @@ bool pqApplyIcdDescriptionsToWorkbook(const std::string& icdPath,
}
std::vector<std::string> orderedKeys = sortedKeysByLength(descByDo);
// 第二步:读取 main.cpp 输出的工作簿。该 .xls 实际是 XML可直接用 tinyxml2 修改。
tinyxml2::XMLDocument doc;
std::string workbookXml = readAllBinary(fs::u8path(workbookPath));
tinyxml2::XMLError rc = doc.Parse(workbookXml.c_str(), workbookXml.size());
@@ -245,6 +274,7 @@ bool pqApplyIcdDescriptionsToWorkbook(const std::string& icdPath,
return false;
}
// 第三步:只处理“未匹配的原始数据”页,因为已匹配页已经有明确映射关系。
tinyxml2::XMLElement* sheet = findWorksheet(doc, "未匹配的原始数据");
if (!sheet) {
if (message) *message = "没有找到 sheet: 未匹配的原始数据";
@@ -259,6 +289,7 @@ bool pqApplyIcdDescriptionsToWorkbook(const std::string& icdPath,
return false;
}
// 第四步:逐行读取原始路径,按 DO 前缀回填 DOI 描述。
int matched = 0;
for (tinyxml2::XMLElement* row = header ? header->NextSiblingElement("Row") : nullptr;
row;

View File

@@ -29,11 +29,31 @@
#include <unordered_map>
#include <unordered_set>
/*
* interface.cpp
*
* 本文件实现 Win32 桌面界面和页面交互,是用户操作到业务函数的“调度层”。
*
* 页面结构:
* 1. 配置项页填写前置、接口、MQ Topic/Tag/Key 等配置。
* 2. 台账列表页:获取台账/ICD选择装置和测点执行初始化 MQ、数据追踪、一键解析。
* 3. JSON 详情页展示已发送追踪请求、DATATopic jsondata、TraceTopic origin。
* 4. 表格预览页:读取 main.cpp 输出的 Excel XML按状态/相位/搜索过滤,可编辑配置列。
* 5. XML 预览页:根据预览页编辑结果反向写回 XML并支持导出。
*
* 关键交互流程:
* runGetLedger() -> pqFetchLedgerAndIcd() -> populateTree()/updatePathsForSelection()
* runTrace() -> pqSendTraceRequest() -> MQ 回调 addJsonEvent() -> autoParseTraceDir()
* runParse() -> pqRunParser() -> refreshPreviewFromWorkbook()
* runReverseXml()-> generateReverseXml() -> XML 预览页
*/
namespace {
using json = nlohmann::json;
namespace fs = std::filesystem;
// 所有控件 ID 集中在这里WM_COMMAND/WM_NOTIFY 根据这些 ID 路由事件。
enum ControlId {
IDC_TAB = 900,
IDC_PAGE_CONFIG,
@@ -59,6 +79,9 @@ enum ControlId {
IDC_TOPIC_LOG,
IDC_TAG_LOG,
IDC_KEY_LOG,
IDC_TOPIC_ASK,
IDC_TAG_ASK,
IDC_KEY_ASK,
IDC_CONSUMER,
IDC_CONSUMER_IPPORT,
IDC_DATA_STAT_TOPIC,
@@ -111,6 +134,7 @@ struct NodeRef {
int monitor = -1;
};
// JSON 树中的一条记录,可能来自 sent/data/trace 三种来源。
struct JsonItem {
std::string kind;
std::string title;
@@ -133,11 +157,13 @@ struct JsonMonitorChoice {
};
struct PreviewSheetRow {
std::vector<std::string> cells;
std::vector<std::string> originalCells;
std::vector<std::string> cellStyles;
std::string phase;
int status = 0;
std::vector<std::string> cells; // 当前可见/可编辑的单元格文本。
std::vector<std::string> originalCells; // 从工作簿读出的原始单元格,用于判断逆向生成时哪些行被改过。
std::vector<std::string> cellStyles; // Excel XML 中的 ss:StyleID用于 GUI 着色。
std::string phase; // A/B/C/T用于相位过滤和同步编辑。
int status = 0; // 0 空白1 正常2 异常3 缺失4 表头5 分段6 子分组。
bool finalRangeChanged = false; // 字段数组范围因表格参数修改而变化,字段格显示深红色。
int modifiedOriginPickColumn = -1; // 修改后的原始值取值起点列GUI 用紫色标记。
};
struct PreviewSheet {
@@ -186,15 +212,15 @@ struct AppState {
HFONT previewFont = nullptr;
HFONT xmlFont = nullptr;
bool ownsFont = false;
std::vector<PqLedgerDevice> devices;
std::vector<PqLedgerDevice> devices; // 当前台账数据,左侧树和追踪请求都从这里取装置/测点。
std::vector<std::unique_ptr<NodeRef>> refs;
std::vector<JsonItem> jsonItems;
std::vector<std::unique_ptr<JsonNodeRef>> jsonRefs;
std::vector<JsonMonitorChoice> jsonMonitorChoices;
std::vector<PreviewWorkbookChoice> previewWorkbooks;
std::vector<PreviewSheet> previewSheets;
std::string activeTraceDir;
std::string activePreviewWorkbook;
std::string activeTraceDir; // 当前测点追踪目录,用于过滤 JSON 记录和自动解析。
std::string activePreviewWorkbook; // 当前表格预览使用的 .xls。
std::string pendingJsonMonitorTraceDir;
std::string generatedXmlText;
std::string generatedXmlSourcePath;
@@ -205,8 +231,10 @@ struct AppState {
bool autoPreviewEnabled = false;
bool jsonMonitorRefreshing = false;
bool jsonMonitorRefreshPending = false;
bool reverseXmlDirty = true;
bool autoParseBusy = false;
bool reverseXmlDirty = true; // 预览表编辑后置 trueXML 预览/搜索前会重新生成。
bool updatingXmlPreview = false; // SetWindowText 会触发 EN_CHANGE用它区分自动刷新和人工编辑。
bool xmlPreviewEdited = false;
bool autoParseBusy = false; // MQ 数据到达后自动解析时显示忙碌提示。
int activePreviewSheet = 0;
int previewZoomPercent = 100;
int previewStatusFilter = -1;
@@ -361,28 +389,21 @@ void appendLog(AppState& app, const std::string& line) {
appendLogW(app, u8w(line));
}
std::string paddedLineNumber(int line, int width) {
std::string text = std::to_string(line);
if ((int)text.size() < width) text.insert(text.begin(), width - text.size(), ' ');
return text;
}
std::string xmlPreviewDisplayText(const std::string& xml) {
if (xml.empty()) return "";
int lines = 1;
for (char c : xml) {
if (c == '\n') ++lines;
// 可编辑预览必须展示原始 XML不能再混入行号否则导出后不是合法 XML。
std::string out;
out.reserve(xml.size() + xml.size() / 20);
for (size_t i = 0; i < xml.size(); ++i) {
if (xml[i] == '\r') {
if (i + 1 < xml.size() && xml[i + 1] == '\n') ++i;
out += "\r\n";
} else if (xml[i] == '\n') {
out += "\r\n";
} else {
out.push_back(xml[i]);
}
}
int width = std::max(3, (int)std::to_string(lines).size());
std::ostringstream out;
std::stringstream ss(xml);
std::string line;
int lineNo = 1;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
out << paddedLineNumber(lineNo++, width) << " " << line << "\r\n";
}
return out.str();
return out;
}
bool highlightXmlSearch(AppState& app, bool focusPreview = false, bool findNext = false) {
@@ -425,10 +446,20 @@ bool highlightXmlSearch(AppState& app, bool focusPreview = false, bool findNext
void updateXmlPreviewText(AppState& app) {
if (!app.xmlPreview) return;
app.updatingXmlPreview = true;
SetWindowTextW(app.xmlPreview, u8w(xmlPreviewDisplayText(app.generatedXmlText)).c_str());
app.updatingXmlPreview = false;
app.xmlPreviewEdited = false;
highlightXmlSearch(app, false, false);
}
void syncXmlPreviewFromEditor(AppState& app) {
if (!app.xmlPreview || app.updatingXmlPreview) return;
app.generatedXmlText = getText(app.hwnd, IDC_XML_PREVIEW_EDIT);
app.reverseXmlDirty = false;
app.xmlPreviewEdited = true;
}
void showBusyPopup(AppState& app, const wchar_t* message) {
if (!app.hwnd) return;
if (app.busyPopup && IsWindow(app.busyPopup)) {
@@ -545,6 +576,7 @@ std::string selectedFrontTypeFromWindow(HWND hwnd) {
PqToolConfig readConfig(HWND hwnd) {
PqToolConfig cfg = pqDefaultConfig();
// GUI 编辑框是配置最终来源;先用默认值兜底,再逐项用界面内容覆盖。
cfg.frontInst = getText(hwnd, IDC_FRONT_INST);
cfg.frontIp = getText(hwnd, IDC_FRONT_IP);
cfg.terminalStatus = getText(hwnd, IDC_TERMINAL_STATUS);
@@ -561,6 +593,9 @@ PqToolConfig readConfig(HWND hwnd) {
cfg.topicLOG = getText(hwnd, IDC_TOPIC_LOG);
cfg.tagLOG = getText(hwnd, IDC_TAG_LOG);
cfg.keyLOG = getText(hwnd, IDC_KEY_LOG);
cfg.topicAsk = getText(hwnd, IDC_TOPIC_ASK);
cfg.tagAsk = getText(hwnd, IDC_TAG_ASK);
cfg.keyAsk = getText(hwnd, IDC_KEY_ASK);
cfg.consumer = getText(hwnd, IDC_CONSUMER);
cfg.consumerIpport = getText(hwnd, IDC_CONSUMER_IPPORT);
cfg.consumerAccessKey = getText(hwnd, IDC_CONSUMER_ACCESS_KEY);
@@ -572,6 +607,7 @@ PqToolConfig readConfig(HWND hwnd) {
cfg.dataRealtimeTag = getText(hwnd, IDC_DATA_REALTIME_TAG);
cfg.dataRealtimeKey = getText(hwnd, IDC_DATA_REALTIME_KEY);
std::string frontType = selectedFrontTypeFromWindow(hwnd);
// frontType 是统计/实时模式开关:决定本次追踪和解析关注哪个 DATATopic。
if (frontType == "cfg_3s_data") {
cfg.dataTopic = cfg.dataRealtimeTopic;
cfg.dataTag = cfg.dataRealtimeTag;
@@ -674,6 +710,7 @@ void populateTree(AppState& app) {
app.selectedDevice = -1;
app.selectedMonitor = -1;
// 台账树按进程号分组maxProcessNum 可让没有设备的进程也显示出来。
int maxProcess = 0;
for (const auto& d : app.devices) {
maxProcess = std::max(maxProcess, toInt(d.processNo));
@@ -999,6 +1036,7 @@ void refreshPreviewWorkbookChoices(AppState& app, const std::string& selectedPat
void resetJsonSession(AppState& app, const std::string& traceDir) {
app.activeTraceDir = traceDir;
// 切换测点追踪时只清掉当前 traceDir 对应的 JSON 记录,其他测点历史还能通过下拉框找回。
app.jsonItems.erase(std::remove_if(app.jsonItems.begin(), app.jsonItems.end(),
[&](const JsonItem& item) {
if (!item.traceDir.empty() && sameTraceDir(item.traceDir, traceDir)) return true;
@@ -1084,6 +1122,7 @@ void addJsonEvent(AppState& app, const PqMqEvent& ev) {
if (item.kind == "data") {
item.dataType = dataTypeFromJsonText(item.body);
if (item.dataType.empty()) item.dataType = "unknown";
// 同一追踪目录下同一个 DATA_TYPE 保留最新一条,避免列表里堆积重复 MQ 消息。
for (size_t i = 0; i < app.jsonItems.size(); ++i) {
auto& existing = app.jsonItems[i];
if (existing.kind == "data" &&
@@ -1142,6 +1181,7 @@ std::string compactCells(const std::vector<std::string>& cells, size_t start) {
}
int previewStatusFromStyle(const std::string& style) {
// 这里把 main.cpp 写出的 Excel 样式名转换成 GUI 过滤/着色用的状态码。
if (style == "MatchGreen") return 1;
if (style == "MatchOrange") return 2;
if (style == "MatchRed") return 3;
@@ -1160,6 +1200,7 @@ std::vector<std::string> rowCells(const tinyxml2::XMLElement* row,
for (const tinyxml2::XMLElement* cell = row ? row->FirstChildElement("Cell") : nullptr;
cell;
cell = cell->NextSiblingElement("Cell")) {
// Excel XML 允许跳列,例如 <Cell ss:Index="8">;读取时必须补齐前面的空列。
const char* indexAttr = cell->Attribute("ss:Index");
if (indexAttr && *indexAttr) col = std::max(1, std::atoi(indexAttr));
if ((int)cells.size() < col) {
@@ -1205,7 +1246,7 @@ void resetPreviewColumns(HWND list, size_t colCount) {
LVCOLUMNW col{};
col.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_SUBITEM;
col.pszText = title.data();
int baseWidth = i == 0 ? 210 : (i == 4 || i == 6 ? 300 : 110);
int baseWidth = i == 0 ? 210 : (i == 4 || i == 6 || i == 8 ? 300 : 110);
col.cx = std::max(45, baseWidth * zoom / 100);
col.iSubItem = static_cast<int>(i);
ListView_InsertColumn(list, static_cast<int>(i), &col);
@@ -1327,6 +1368,7 @@ void populatePreviewList(AppState& app, int forcedTopModelRow = -1, int forcedHo
resetPreviewColumns(app.previewList, sheet.maxCols);
int statusFilter = app.previewStatusFilter;
int visibleRows = 0;
// ListView 只显示过滤后的行,但 lParam 仍保存原始模型行号,编辑时用它回写 previewSheets。
for (size_t i = 0; i < sheet.rows.size(); ++i) {
const PreviewSheetRow& row = sheet.rows[i];
if (!previewRowMatchesFilter(row, statusFilter)) continue;
@@ -1359,6 +1401,7 @@ bool isEditablePreviewCell(const AppState& app, int rowIndex, int colIndex) {
const auto& rows = app.previewSheets[app.activePreviewSheet].rows;
if (rowIndex < 0 || rowIndex >= (int)rows.size()) return false;
const PreviewSheetRow& row = rows[rowIndex];
// 只允许改“数组基准/取值偏移量/最终值偏移/配置项”这几列,避免误改最终值或 origin 值。
if (colIndex < 1 || colIndex > 4) return false;
if (!(row.status == 1 || row.status == 2 || row.status == 3)) return false;
return !row.cells.empty() && !row.cells[0].empty();
@@ -1379,6 +1422,7 @@ void syncPreviewPhaseGroup(AppState& app, int rowIndex, int colIndex, const std:
const PreviewSheetRow& row = sheet.rows[rowIndex];
if (!isAbcPhase(row.phase) || row.cells.empty()) return;
std::string baseLabel = labelWithoutPhase(row.cells[0]);
// A/B/C 三相同一个指标通常应该保持同一配置;编辑一相时同步另外两相。
for (auto& other : sheet.rows) {
if (!isAbcPhase(other.phase) || other.cells.empty() || other.cells[0].empty()) continue;
if (labelWithoutPhase(other.cells[0]) != baseLabel) continue;
@@ -1387,6 +1431,108 @@ void syncPreviewPhaseGroup(AppState& app, int rowIndex, int colIndex, const std:
}
}
bool parsePreviewRange(const std::string& text, int& start, int& end) {
std::smatch m;
static const std::regex re(R"(^\s*(-?\d+)\s*-->\s*(-?\d+)\s*$)");
if (!std::regex_match(text, m, re)) return false;
try {
start = std::stoi(m[1].str());
end = std::stoi(m[2].str());
return start <= end;
} catch (...) {
return false;
}
}
bool parsePreviewFieldRange(const std::string& field, int& start, int& end) {
std::smatch m;
static const std::regex re(R"(\[(-?\d+)-(-?\d+)\])");
if (!std::regex_search(field, m, re)) return false;
try {
start = std::stoi(m[1].str());
end = std::stoi(m[2].str());
return start <= end;
} catch (...) {
return false;
}
}
int parsePreviewOffset(const std::string& text) {
try {
std::string value = trimAscii(text);
return value.empty() ? 0 : std::stoi(value);
} catch (...) {
return 0;
}
}
std::string replacePreviewFieldRange(const std::string& field, int start, int end) {
static const std::regex re(R"(\[(-?\d+)-(-?\d+)\])");
return std::regex_replace(field, re,
"[" + std::to_string(start) + "-" + std::to_string(end) + "]",
std::regex_constants::format_first_only);
}
std::string effectivePreviewField(const std::string& field) {
size_t arrow = field.rfind(" >> ");
return arrow == std::string::npos ? field : field.substr(arrow + 4);
}
void recalculatePreviewEditEffects(PreviewSheet& sheet) {
for (auto& row : sheet.rows) {
row.finalRangeChanged = false;
row.modifiedOriginPickColumn = -1;
// 字段列不可直接编辑,因此每次从工作簿原值重新计算,恢复参数时可无损恢复。
if (!row.cells.empty() && !row.cells[0].empty() && row.originalCells.size() > 8) {
if (row.cells.size() <= 8) row.cells.resize(9);
row.cells[8] = row.originalCells[8];
}
}
for (size_t i = 0; i < sheet.rows.size(); ++i) {
PreviewSheetRow& row = sheet.rows[i];
if (!(row.status == 1 || row.status == 2 || row.status == 3)) continue;
if (row.cells.empty() || row.cells[0].empty() || row.cells.size() <= 8 || row.originalCells.size() <= 8) continue;
int oldBaseStart = 0, oldBaseEnd = 0, newBaseStart = 0, newBaseEnd = 0;
bool oldBaseOk = row.originalCells.size() > 1 && parsePreviewRange(row.originalCells[1], oldBaseStart, oldBaseEnd);
bool newBaseOk = row.cells.size() > 1 && parsePreviewRange(row.cells[1], newBaseStart, newBaseEnd);
int oldValueOffset = row.originalCells.size() > 2 ? parsePreviewOffset(row.originalCells[2]) : 0;
int newValueOffset = row.cells.size() > 2 ? parsePreviewOffset(row.cells[2]) : 0;
int oldFinalOffset = row.originalCells.size() > 3 ? parsePreviewOffset(row.originalCells[3]) : 0;
int newFinalOffset = row.cells.size() > 3 ? parsePreviewOffset(row.cells[3]) : 0;
int oldFieldStart = 0, oldFieldEnd = 0;
if (oldBaseOk && newBaseOk && parsePreviewFieldRange(row.originalCells[8], oldFieldStart, oldFieldEnd)) {
int newFieldStart = oldFieldStart + (newBaseStart - oldBaseStart) + (newFinalOffset - oldFinalOffset);
int newFieldEnd = oldFieldEnd + (newBaseEnd - oldBaseEnd) + (newFinalOffset - oldFinalOffset);
std::string newField = replacePreviewFieldRange(row.originalCells[8], newFieldStart, newFieldEnd);
if (newField != row.originalCells[8]) {
row.cells[8] = row.originalCells[8] + " >> " + newField;
row.finalRangeChanged = true;
}
}
if (!oldBaseOk || !newBaseOk || i + 1 >= sheet.rows.size()) continue;
PreviewSheetRow& originRow = sheet.rows[i + 1];
if (originRow.cells.size() <= 8 || originRow.cells[7] != "原始值") continue;
int originalBlueColumn = -1;
for (size_t col = 9; col < originRow.cellStyles.size(); ++col) {
if (originRow.cellStyles[col] == "OriginPickBlue") {
originalBlueColumn = static_cast<int>(col);
break;
}
}
if (originalBlueColumn < 0) continue;
int oldPickStart = oldBaseStart + oldValueOffset;
int newPickStart = newBaseStart + newValueOffset;
int purpleColumn = originalBlueColumn + (newPickStart - oldPickStart);
if (newPickStart != oldPickStart && purpleColumn >= 9 && purpleColumn < (int)originRow.cells.size()) {
originRow.modifiedOriginPickColumn = purpleColumn;
}
}
}
void commitPreviewEdit(AppState& app, bool save) {
HWND edit = app.previewEdit;
if (!edit) return;
@@ -1411,6 +1557,7 @@ void commitPreviewEdit(AppState& app, bool save) {
if ((int)row.cells.size() <= colIndex) row.cells.resize(colIndex + 1);
row.cells[colIndex] = value;
syncPreviewPhaseGroup(app, rowIndex, colIndex, value);
recalculatePreviewEditEffects(app.previewSheets[app.activePreviewSheet]);
app.reverseXmlDirty = true;
}
@@ -1473,6 +1620,7 @@ void refreshPreviewFromWorkbook(AppState& app, const std::string& workbookPath)
return;
}
// 工作簿是 Excel 2003 XML 格式,不需要 COM/Excel直接用 tinyxml2 读 Worksheet/Row/Cell。
tinyxml2::XMLDocument doc;
std::string xml = readAllText(fs::u8path(workbookPath));
if (doc.Parse(xml.c_str(), xml.size()) != tinyxml2::XML_SUCCESS) {
@@ -1803,8 +1951,11 @@ void showPage(AppState& app, int index) {
refreshPreviewFromWorkbook(app, selected);
}
} else if (index == 4) {
if (app.reverseXmlDirty || app.generatedXmlText.empty()) generateReverseXml(app, false);
updateXmlPreviewText(app);
// XML 有人工修改时不自动逆生成,避免仅切换页面就静默覆盖用户内容。
if (!app.xmlPreviewEdited) {
if (app.reverseXmlDirty || app.generatedXmlText.empty()) generateReverseXml(app, false);
else updateXmlPreviewText(app);
}
}
}
@@ -1818,6 +1969,7 @@ void runGetLedger(AppState& app) {
PqToolConfig cfg = readConfig(app.hwnd);
appendLogW(app, L"开始获取台账和 ICD/XML...");
std::string firstXml;
// 输出 devices 会刷新左侧树firstXml 会自动填到 XML 路径,减少手动选择。
pqFetchLedgerAndIcd(cfg, app.devices, firstXml, [&](const std::string& s) { appendLog(app, s); });
populateTree(app);
updateDetails(app);
@@ -1851,16 +2003,17 @@ void runTrace(AppState& app) {
const auto& d = app.devices[app.selectedDevice];
const auto& m = d.monitors[app.selectedMonitor];
std::string traceDir = pqTraceDirectoryFor(cfg, d, m);
// pqSendTraceRequest 会在 MQ 互斥锁内建立唯一活动会话并清空旧记录,
// 从这一刻开始只接收 Broker 存储时间不早于本次点击的消息。
resetJsonSession(app, traceDir);
pqClearTraceRecords(cfg, d, m);
appendLog(app, "已清空当前测点旧文本记录: " + traceDir);
pqSendTraceRequest(cfg, d, m, selectedFrontType(app),
[&](const std::string& s) { appendLog(app, s); },
makeMqEventCallback(app));
appendLog(app, "已建立本次追踪消息时间边界并清空旧记录: " + traceDir);
prepareTraceWorkspace(app, cfg, d, m, true);
app.autoPreviewEnabled = true;
selectPage(app, 3);
autoParseTraceDir(app, traceDir, "trace-start");
pqSendTraceRequest(cfg, d, m, selectedFrontType(app),
[&](const std::string& s) { appendLog(app, s); },
makeMqEventCallback(app));
} catch (const std::exception& e) {
appendLog(app, std::string("数据追踪失败: ") + e.what());
}
@@ -1871,6 +2024,7 @@ void runParse(AppState& app) {
std::string err;
appendLogW(app, L"开始解析生成表格...");
std::vector<std::string> jsonPaths = cfg.jsonDataPaths.empty() ? splitPathList(cfg.jsonDataPath) : cfg.jsonDataPaths;
// 一键解析是手动路径模式:用界面上的 XML/jsondata/origin/output 调用核心解析器。
bool ok = pqRunParser(cfg.xmlPath, jsonPaths, cfg.originPaths, cfg.outputPath, &err, selectedFrontType(app));
if (ok) {
refreshPreviewWorkbookChoices(app, cfg.outputPath);
@@ -1971,6 +2125,7 @@ std::string reverseNameFromField(const std::string& field, const std::string& ar
std::smatch m;
static const std::regex rangeRe(R"(^(.*)\[(\d+)-(\d+)\](.*)$)");
if (std::regex_match(name, m, rangeRe)) {
// 表格里序列显示为 [start-end],写回 XML 时恢复为 %start,end%。
std::string start = m[2].str();
std::string end = m[3].str();
static const std::regex baseRe(R"(^\s*(-?\d+)\s*-->\s*(-?\d+)\s*$)");
@@ -2014,6 +2169,7 @@ std::string placeholderForValueOffset(const std::string& offsetText) {
std::string reverseDaForXml(std::string daPath, const std::string& valueOffset) {
if (valueOffset.empty()) return daPath;
static const std::regex rangeRe(R"(\[\d+-\d+\])");
// 表格展示的 [start-end] 在 XML 中是 [%offset] 占位符,按“取值偏移量”恢复。
return std::regex_replace(daPath, rangeRe, "[" + placeholderForValueOffset(valueOffset) + "]",
std::regex_constants::format_first_only);
}
@@ -2035,6 +2191,7 @@ std::vector<ReverseRowEdit> collectReverseRows(const AppState& app) {
std::vector<ReverseRowEdit> rows;
if (app.activePreviewSheet < 0 || app.activePreviewSheet >= (int)app.previewSheets.size()) return rows;
const PreviewSheet& sheet = app.previewSheets[app.activePreviewSheet];
// 只采集正常/异常/缺失这些数据行,表头和分组行不会参与反向生成。
for (const auto& row : sheet.rows) {
if (!(row.status == 1 || row.status == 2 || row.status == 3)) continue;
if (row.cells.size() <= 8 || row.cells[0].empty()) continue;
@@ -2049,7 +2206,7 @@ std::vector<ReverseRowEdit> collectReverseRows(const AppState& app) {
parseConfigText(originalConfig, edit.originalDoPath, edit.originalDaPath);
edit.modified = row.cells != row.originalCells;
edit.daPath = reverseDaForXml(edit.daPath, edit.valueOffset);
edit.name = reverseNameFromField(row.cells[8], edit.arrayBase);
edit.name = reverseNameFromField(effectivePreviewField(row.cells[8]), edit.arrayBase);
if (!edit.name.empty() || !edit.label.empty()) rows.push_back(std::move(edit));
}
std::stable_sort(rows.begin(), rows.end(), [](const ReverseRowEdit& a, const ReverseRowEdit& b) {
@@ -2092,6 +2249,7 @@ bool reverseNodeMatches(const ValueNodeRef& node, const ReverseSheetSpec& spec,
if (node.topic != spec.topic || node.dataType != spec.dataType || !reverseWiringAllowed(node.wiring, spec.wiring)) return false;
std::string nodeDo = xmlAttr(node.element, "DO");
std::string nodeDa = xmlAttr(node.element, "DA");
// 优先用原始 DO/DA 精确定位;如果用户把 DO/DA 改了,也能找到原 XML 节点。
if (!edit.originalDoPath.empty() && !edit.originalDaPath.empty() &&
nodeDo == edit.originalDoPath && nodeDa == edit.originalDaPath) {
return true;
@@ -2121,6 +2279,7 @@ std::string generateReverseXml(AppState& app, bool showMessage) {
return "";
}
// 当前 Sheet 名决定要修改 XML 中哪个 Topic/DataType/接线分支。
ReverseSheetSpec spec = reverseSpecForSheet(app.previewSheets[app.activePreviewSheet].name);
std::vector<ReverseRowEdit> edits = collectReverseRows(app);
std::vector<ValueNodeRef> nodes;
@@ -2156,12 +2315,28 @@ std::string generateReverseXml(AppState& app, bool showMessage) {
}
void runReverseXml(AppState& app) {
if (app.xmlPreviewEdited) {
int choice = MessageBoxW(app.hwnd,
L"XML预览此前有过手动修改。继续逆生成会覆盖这些人工修改是否继续",
L"确认覆盖 XML 人工修改",
MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2);
if (choice != IDYES) return;
}
if (!generateReverseXml(app, true).empty()) selectPage(app, 4);
}
void runExportXml(AppState& app) {
if (app.xmlPreviewEdited) syncXmlPreviewFromEditor(app);
if (app.generatedXmlText.empty() || app.reverseXmlDirty) generateReverseXml(app, false);
if (app.generatedXmlText.empty()) return;
tinyxml2::XMLDocument check;
if (check.Parse(app.generatedXmlText.c_str(), app.generatedXmlText.size()) != tinyxml2::XML_SUCCESS) {
MessageBoxW(app.hwnd,
u8w(std::string("当前编辑内容不是合法 XML") + check.ErrorStr()).c_str(),
L"导出 XML 失败", MB_ICONERROR);
return;
}
std::string out;
if (!saveFileDialog(app.hwnd, L"\x5BFC\x51FAXML", L"XML Files\0*.xml;*.icd\0All Files\0*.*\0", L"xml", out)) return;
std::ofstream ofs(fs::u8path(out), std::ios::binary);
@@ -2181,6 +2356,7 @@ void autoParseTraceDir(AppState& app, const std::string& traceDir, const char* r
std::string outputPath = (dir / "final_sorted.xls").u8string();
std::string frontType = selectedFrontType(app);
// trace_context.json 是 pqSendTraceRequest() 写下的上下文,自动解析优先用它定位 XML 和输出路径。
fs::path contextPath = dir / "trace_context.json";
if (fs::exists(contextPath)) {
try {
@@ -2279,21 +2455,25 @@ void createConfigPage(AppState& app) {
addLabeledEdit(app, p, L"TopicLOG", IDC_TOPIC_LOG, cfg.topicLOG, 120, 398 + rowGap * 2, 90, 360);
addLabeledEdit(app, p, L"TagLOG", IDC_TAG_LOG, cfg.tagLOG, 575, 398 + rowGap * 2, 75, 270);
addLabeledEdit(app, p, L"KeyLOG", IDC_KEY_LOG, cfg.keyLOG, 940, 398 + rowGap * 2, 70, 120);
addLabeledEdit(app, p, L"TopicAsk", IDC_TOPIC_ASK, cfg.topicAsk, 120, 398 + rowGap * 3, 90, 360);
addLabeledEdit(app, p, L"TagAsk", IDC_TAG_ASK, cfg.tagAsk, 575, 398 + rowGap * 3, 75, 270);
addLabeledEdit(app, p, L"KeyAsk", IDC_KEY_ASK, cfg.keyAsk, 940, 398 + rowGap * 3, 70, 120);
addLabel(app, p, L"消费者", x1, 512, 80, 22);
addLabeledEdit(app, p, L"consumer", IDC_CONSUMER, cfg.consumer, 120, 512, 90, 300);
addLabeledEdit(app, p, L"Ipport", IDC_CONSUMER_IPPORT, cfg.consumerIpport, 600, 512, 80, 260);
addLabeledEdit(app, p, L"AccessKey", IDC_CONSUMER_ACCESS_KEY, cfg.consumerAccessKey, 120, 512 + rowGap, 90, 300);
addLabeledEdit(app, p, L"SecretKey", IDC_CONSUMER_SECRET_KEY, cfg.consumerSecretKey, 600, 512 + rowGap, 80, 260);
addLabeledEdit(app, p, L"\x7EDF\x8BA1Topic", IDC_DATA_STAT_TOPIC, cfg.dataStatTopic, 120, 512 + rowGap * 2, 90, 360);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_STAT_TAG, cfg.dataStatTag, 575, 512 + rowGap * 2, 75, 270);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_STAT_KEY, cfg.dataStatKey, 940, 512 + rowGap * 2, 70, 120);
addLabeledEdit(app, p, L"\x5B9E\x65F6Topic", IDC_DATA_REALTIME_TOPIC, cfg.dataRealtimeTopic, 120, 512 + rowGap * 3, 90, 360);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_REALTIME_TAG, cfg.dataRealtimeTag, 575, 512 + rowGap * 3, 75, 270);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_REALTIME_KEY, cfg.dataRealtimeKey, 940, 512 + rowGap * 3, 70, 120);
addLabeledEdit(app, p, L"TraceTopic", IDC_TRACE_TOPIC, cfg.traceTopic, 120, 512 + rowGap * 4, 90, 360);
addLabeledEdit(app, p, L"TraceTag", IDC_TRACE_TAG, cfg.traceTag, 575, 512 + rowGap * 4, 75, 270);
addLabeledEdit(app, p, L"TraceKey", IDC_TRACE_KEY, cfg.traceKey, 940, 512 + rowGap * 4, 70, 120);
const int consumerY = 526;
addLabel(app, p, L"消费者", x1, consumerY, 80, 22);
addLabeledEdit(app, p, L"consumer", IDC_CONSUMER, cfg.consumer, 120, consumerY, 90, 300);
addLabeledEdit(app, p, L"Ipport", IDC_CONSUMER_IPPORT, cfg.consumerIpport, 600, consumerY, 80, 260);
addLabeledEdit(app, p, L"AccessKey", IDC_CONSUMER_ACCESS_KEY, cfg.consumerAccessKey, 120, consumerY + rowGap, 90, 300);
addLabeledEdit(app, p, L"SecretKey", IDC_CONSUMER_SECRET_KEY, cfg.consumerSecretKey, 600, consumerY + rowGap, 80, 260);
addLabeledEdit(app, p, L"\x7EDF\x8BA1Topic", IDC_DATA_STAT_TOPIC, cfg.dataStatTopic, 120, consumerY + rowGap * 2, 90, 360);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_STAT_TAG, cfg.dataStatTag, 575, consumerY + rowGap * 2, 75, 270);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_STAT_KEY, cfg.dataStatKey, 940, consumerY + rowGap * 2, 70, 120);
addLabeledEdit(app, p, L"\x5B9E\x65F6Topic", IDC_DATA_REALTIME_TOPIC, cfg.dataRealtimeTopic, 120, consumerY + rowGap * 3, 90, 360);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_REALTIME_TAG, cfg.dataRealtimeTag, 575, consumerY + rowGap * 3, 75, 270);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_REALTIME_KEY, cfg.dataRealtimeKey, 940, consumerY + rowGap * 3, 70, 120);
addLabeledEdit(app, p, L"TraceTopic", IDC_TRACE_TOPIC, cfg.traceTopic, 120, consumerY + rowGap * 4, 90, 360);
addLabeledEdit(app, p, L"TraceTag", IDC_TRACE_TAG, cfg.traceTag, 575, consumerY + rowGap * 4, 75, 270);
addLabeledEdit(app, p, L"TraceKey", IDC_TRACE_KEY, cfg.traceKey, 940, consumerY + rowGap * 4, 70, 120);
}
void createLedgerPage(AppState& app) {
@@ -2429,7 +2609,7 @@ void createXmlPreviewPage(AppState& app) {
app.xmlSearch = addEdit(app, p, IDC_XML_SEARCH, "", 230, 12, 360, 28);
app.xmlPreview = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"",
WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE |
ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_NOHIDESEL |
ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_NOHIDESEL | ES_WANTRETURN |
WS_VSCROLL | WS_HSCROLL,
18, 50, 1115, 585, p,
(HMENU)(INT_PTR)IDC_XML_PREVIEW_EDIT, app.inst, nullptr);
@@ -2437,7 +2617,7 @@ void createXmlPreviewPage(AppState& app) {
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
CLEARTYPE_QUALITY, FIXED_PITCH | FF_MODERN, L"Consolas");
SendMessageW(app.xmlPreview, WM_SETFONT, (WPARAM)(app.xmlFont ? app.xmlFont : app.font), TRUE);
SendMessageW(app.xmlPreview, EM_SETREADONLY, TRUE, 0);
SendMessageW(app.xmlPreview, EM_SETLIMITTEXT, 0x7FFFFFFE, 0);
}
void createConfigPageClean(AppState& app) {
@@ -2472,21 +2652,25 @@ void createConfigPageClean(AppState& app) {
addLabeledEdit(app, p, L"TopicLOG", IDC_TOPIC_LOG, cfg.topicLOG, mqFieldX, 398 + rowGap * 2, 90, mqTopicW);
addLabeledEdit(app, p, L"TagLOG", IDC_TAG_LOG, cfg.tagLOG, mqTagX, 398 + rowGap * 2, 75, mqTagW);
addLabeledEdit(app, p, L"KeyLOG", IDC_KEY_LOG, cfg.keyLOG, mqKeyX, 398 + rowGap * 2, 70, mqKeyW);
addLabeledEdit(app, p, L"TopicAsk", IDC_TOPIC_ASK, cfg.topicAsk, mqFieldX, 398 + rowGap * 3, 90, mqTopicW);
addLabeledEdit(app, p, L"TagAsk", IDC_TAG_ASK, cfg.tagAsk, mqTagX, 398 + rowGap * 3, 75, mqTagW);
addLabeledEdit(app, p, L"KeyAsk", IDC_KEY_ASK, cfg.keyAsk, mqKeyX, 398 + rowGap * 3, 70, mqKeyW);
addLabel(app, p, L"\x6D88\x8D39\x8005", mqNameX, 512, 90, 22);
addLabeledEdit(app, p, L"consumer", IDC_CONSUMER, cfg.consumer, mqFieldX, 512, 90, 300);
addLabeledEdit(app, p, L"Ipport", IDC_CONSUMER_IPPORT, cfg.consumerIpport, mqTagX, 512, 80, 260);
addLabeledEdit(app, p, L"AccessKey", IDC_CONSUMER_ACCESS_KEY, cfg.consumerAccessKey, mqFieldX, 512 + rowGap, 90, 300);
addLabeledEdit(app, p, L"SecretKey", IDC_CONSUMER_SECRET_KEY, cfg.consumerSecretKey, mqTagX, 512 + rowGap, 80, 260);
addLabeledEdit(app, p, L"\x7EDF\x8BA1Topic", IDC_DATA_STAT_TOPIC, cfg.dataStatTopic, mqFieldX, 512 + rowGap * 2, 90, mqTopicW);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_STAT_TAG, cfg.dataStatTag, mqTagX, 512 + rowGap * 2, 75, mqTagW);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_STAT_KEY, cfg.dataStatKey, mqKeyX, 512 + rowGap * 2, 70, mqKeyW);
addLabeledEdit(app, p, L"\x5B9E\x65F6Topic", IDC_DATA_REALTIME_TOPIC, cfg.dataRealtimeTopic, mqFieldX, 512 + rowGap * 3, 90, mqTopicW);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_REALTIME_TAG, cfg.dataRealtimeTag, mqTagX, 512 + rowGap * 3, 75, mqTagW);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_REALTIME_KEY, cfg.dataRealtimeKey, mqKeyX, 512 + rowGap * 3, 70, mqKeyW);
addLabeledEdit(app, p, L"TraceTopic", IDC_TRACE_TOPIC, cfg.traceTopic, mqFieldX, 512 + rowGap * 4, 90, mqTopicW);
addLabeledEdit(app, p, L"TraceTag", IDC_TRACE_TAG, cfg.traceTag, mqTagX, 512 + rowGap * 4, 75, mqTagW);
addLabeledEdit(app, p, L"TraceKey", IDC_TRACE_KEY, cfg.traceKey, mqKeyX, 512 + rowGap * 4, 70, mqKeyW);
const int consumerY = 526;
addLabel(app, p, L"\x6D88\x8D39\x8005", mqNameX, consumerY, 90, 22);
addLabeledEdit(app, p, L"consumer", IDC_CONSUMER, cfg.consumer, mqFieldX, consumerY, 90, 300);
addLabeledEdit(app, p, L"Ipport", IDC_CONSUMER_IPPORT, cfg.consumerIpport, mqTagX, consumerY, 80, 260);
addLabeledEdit(app, p, L"AccessKey", IDC_CONSUMER_ACCESS_KEY, cfg.consumerAccessKey, mqFieldX, consumerY + rowGap, 90, 300);
addLabeledEdit(app, p, L"SecretKey", IDC_CONSUMER_SECRET_KEY, cfg.consumerSecretKey, mqTagX, consumerY + rowGap, 80, 260);
addLabeledEdit(app, p, L"\x7EDF\x8BA1Topic", IDC_DATA_STAT_TOPIC, cfg.dataStatTopic, mqFieldX, consumerY + rowGap * 2, 90, mqTopicW);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_STAT_TAG, cfg.dataStatTag, mqTagX, consumerY + rowGap * 2, 75, mqTagW);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_STAT_KEY, cfg.dataStatKey, mqKeyX, consumerY + rowGap * 2, 70, mqKeyW);
addLabeledEdit(app, p, L"\x5B9E\x65F6Topic", IDC_DATA_REALTIME_TOPIC, cfg.dataRealtimeTopic, mqFieldX, consumerY + rowGap * 3, 90, mqTopicW);
addLabeledEdit(app, p, L"DATATag", IDC_DATA_REALTIME_TAG, cfg.dataRealtimeTag, mqTagX, consumerY + rowGap * 3, 75, mqTagW);
addLabeledEdit(app, p, L"DATAKey", IDC_DATA_REALTIME_KEY, cfg.dataRealtimeKey, mqKeyX, consumerY + rowGap * 3, 70, mqKeyW);
addLabeledEdit(app, p, L"TraceTopic", IDC_TRACE_TOPIC, cfg.traceTopic, mqFieldX, consumerY + rowGap * 4, 90, mqTopicW);
addLabeledEdit(app, p, L"TraceTag", IDC_TRACE_TAG, cfg.traceTag, mqTagX, consumerY + rowGap * 4, 75, mqTagW);
addLabeledEdit(app, p, L"TraceKey", IDC_TRACE_KEY, cfg.traceKey, mqKeyX, consumerY + rowGap * 4, 70, mqKeyW);
}
void createLedgerPageClean(AppState& app) {
@@ -2534,6 +2718,7 @@ void createLedgerPageClean(AppState& app) {
}
void createUi(AppState& app) {
// 这里手写 Win32 控件,没有资源文件;所有页面尺寸和控件布局都在 create*Page 函数中生成。
app.font = CreateFontW(-16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, L"Microsoft YaHei UI");
@@ -2583,6 +2768,7 @@ LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
AppState& app = *g_app;
switch (msg) {
case WM_PQ_MQ_EVENT: {
// RocketMQ 回调线程不能直接操作窗口mq.cpp 通过 PostMessage 把事件切回 GUI 线程。
std::unique_ptr<PqMqEvent> ev(reinterpret_cast<PqMqEvent*>(lp));
if (ev) addJsonEvent(app, *ev);
return 0;
@@ -2595,6 +2781,7 @@ LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
createUi(app);
return 0;
case WM_COMMAND:
// WM_COMMAND 处理按钮、编辑框、下拉框;每个控件 ID 对应上面的 ControlId。
if (LOWORD(wp) == IDC_PREVIEW_FILTER && HIWORD(wp) == CBN_SELCHANGE) {
int idx = (int)SendMessageW(app.previewFilter, CB_GETCURSEL, 0, 0);
int filters[] = {-1, 1, 2, 3, 0};
@@ -2621,9 +2808,17 @@ LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
highlightXmlSearch(app, false, false);
return 0;
}
if (LOWORD(wp) == IDC_XML_PREVIEW_EDIT && HIWORD(wp) == EN_CHANGE) {
if (!app.updatingXmlPreview) {
syncXmlPreviewFromEditor(app);
app.xmlSearchLastText.clear();
app.xmlSearchNextPos = 0;
}
return 0;
}
if (LOWORD(wp) == IDC_XML_SEARCH_BUTTON && HIWORD(wp) == BN_CLICKED) {
app.xmlSearchText = getText(hwnd, IDC_XML_SEARCH);
if (app.reverseXmlDirty || app.generatedXmlText.empty()) {
if (!app.xmlPreviewEdited && (app.reverseXmlDirty || app.generatedXmlText.empty())) {
generateReverseXml(app, false);
app.xmlSearchLastText.clear();
app.xmlSearchNextPos = 0;
@@ -2726,6 +2921,7 @@ LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
}
break;
case WM_NOTIFY:
// WM_NOTIFY 处理 TreeView/ListView/TabControl 的选择、双击、自绘等通知。
if (((LPNMHDR)lp)->idFrom == IDC_PREVIEW_LIST && ((LPNMHDR)lp)->code == NM_DBLCLK) {
auto* item = reinterpret_cast<NMITEMACTIVATE*>(lp);
if (item->iItem >= 0 && item->iSubItem >= 0) {
@@ -2743,17 +2939,26 @@ LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
size_t idx = static_cast<size_t>(cd->nmcd.lItemlParam);
int status = 0;
std::string cellStyle;
bool finalRangeChanged = false;
int modifiedOriginPickColumn = -1;
if (!app.previewSheets.empty() &&
app.activePreviewSheet >= 0 &&
app.activePreviewSheet < (int)app.previewSheets.size() &&
idx < app.previewSheets[app.activePreviewSheet].rows.size()) {
const PreviewSheetRow& row = app.previewSheets[app.activePreviewSheet].rows[idx];
status = row.status;
finalRangeChanged = row.finalRangeChanged;
modifiedOriginPickColumn = row.modifiedOriginPickColumn;
if (cd->iSubItem >= 0 && cd->iSubItem < (int)row.cellStyles.size()) {
cellStyle = row.cellStyles[cd->iSubItem];
}
}
if (cellStyle == "OriginPickBlue") cd->clrTextBk = RGB(207, 226, 243);
if (finalRangeChanged && cd->iSubItem == 8) {
cd->clrTextBk = RGB(153, 0, 0);
cd->clrText = RGB(255, 255, 255);
}
else if (modifiedOriginPickColumn == cd->iSubItem) cd->clrTextBk = RGB(180, 167, 214);
else if (cellStyle == "OriginPickBlue") cd->clrTextBk = RGB(207, 226, 243);
else if (status == 1) cd->clrTextBk = RGB(217, 234, 211);
else if (status == 2) cd->clrTextBk = RGB(252, 228, 214);
else if (status == 3) cd->clrTextBk = RGB(244, 204, 204);
@@ -2802,6 +3007,7 @@ int runPqGuiApp() {
AppState app;
g_app = &app;
app.inst = GetModuleHandleW(nullptr);
// COM 初始化主要服务于系统组件/对话框;主流程仍是普通 Win32 消息循环。
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
INITCOMMONCONTROLSEX icc{};
@@ -2836,6 +3042,7 @@ int runPqGuiApp() {
ShowWindow(hwnd, SW_SHOW);
UpdateWindow(hwnd);
// 标准消息循环:用户操作 -> wndProc -> run* 业务函数 -> 刷新 AppState/控件。
MSG message;
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message);

View File

@@ -22,26 +22,57 @@
#include "pq_app.h"
#include "tinyxml2.h" // tinyxml2
/*
* main.cpp
*
* 本文件是“映射解析 + 报表生成”的核心,也保留了命令行入口。
*
* 主流程:
* 1. loadValueMetas() 读取 ICD/XML 模板,把每个 <Value> 的 name、desc、DO、DA、
* Topic、DataType、Item、Sequence 等上下文整理成 ValueMeta。
* 2. readJsonDataFile() 读取 DATATopic 的 jsondataflattenJson() 压平成
* Value_... -> value 的键值对mergeSequences() 把连续编号合并成数组。
* 3. buildItemsForSheet() 按 SheetSpec 生成每张工作表的 OutItem 行。
* 4. addOriginFileToContext() 读取 TraceTopic 的 origin/MMS 数据matchOrigin()
* 用 DO + DA + 相位 + 序列范围匹配最终值和原始值。
* 5. writeExcelWorkbook() 输出 Excel 2003 XML 格式的 .xls供 Excel/GUI 预览打开。
*
* 标志/转换要点:
* - Sequence/@value == 7 代表星形接线 star。
* - Sequence/@value == 112 代表角形接线 delta。
* - DATA_TYPE "1" 会规范成 "01",用于匹配统计/闪变/实时 Sheet。
* - 指标名前缀 G_/MAX_/MIN_ 分别转换成 95值/最大值/最小值;无前缀默认平均值。
* - DA 中 phs* 是相位占位符,星形转换为 phsA/phsB/phsC角形转换为
* phsAB/phsBC/phsCA。
* - Value 名称中的 %start,end% 是 XML 序列模板;运行时显示成 [start-end]。
*/
using json = nlohmann::json;
namespace fs = std::filesystem;
/*
* XML <Value> 节点的完整业务元信息。
*
* 输入loadValueMetas() 在递归遍历 XML 时填充。
* 输出:后续用于把 jsondata 的 key 映射成 DO/DA、中文描述、接线方式、Topic/DataType。
*/
struct ValueMeta {
std::string name;
std::string desc;
std::string doPath;
std::string daPath;
std::string wiring = "common"; // star/delta/common
std::string topic;
std::string dataType;
std::string itemName;
std::string seqValue;
bool hasOffset = false;
int offset = 0;
std::string name; // Value/@name可能是普通指标 V1也可能是序列模板 MAX_V_%2,50%。
std::string desc; // Value/@desc最终表格“名称”列主要来自这里。
std::string doPath; // Value/@DOIEC 61850 MMS 对象路径前半段。
std::string daPath; // Value/@DAIEC 61850 MMS 数据属性路径后半段。
std::string wiring = "common"; // star/delta/common:由 Sequence/@value 推断。
std::string topic; // 父级 Topic/@name区分 HISDATA/RTDATA。
std::string dataType; // 父级 DataType/@value区分 01/02/03。
std::string itemName; // 父级 Item/@name如 V/I/PQ/F_S/F_L。
std::string seqValue; // 父级 Sequence/@value后续判断 T 相或接线方式会用到。
bool hasOffset = false; // Value/@Offset 是否存在。
int offset = 0; // 最终值偏移量,用于把最终值数组范围挪到 origin 对齐范围。
bool isSequence = false;
int seqStart = 0; // 例如 %2,50% 里的 2
int seqEnd = 0; // 例如 %2,50% 里的 50
std::string seqPrefixName; // 例如 MAX_V
bool isSequence = false; // name 是否匹配 %start,end% 序列模板。
int seqStart = 0; // 例如 %2,50% 里的 2
int seqEnd = 0; // 例如 %2,50% 里的 50
std::string seqPrefixName; // 例如 MAX_V_%2,50% 的前缀 MAX_V_。
};
static std::string readAll(const fs::path& p) {
@@ -58,6 +89,7 @@ static std::string ltrimBOM(std::string s) {
return s;
}
// DO/DA 中有时会出现 A||A||B 这种重复候选;这里保持顺序并去重,减少表格噪声。
static std::string collapseRepeatedAlternatives(const std::string& s) {
std::vector<std::string> parts;
std::unordered_set<std::string> seen;
@@ -100,6 +132,14 @@ static std::string toStr(const json& v) {
return v.dump();
}
/*
* 把嵌套 JSON 压平成下划线连接的 key。
*
* 输入示例:{"Value":{"V":{"A":{"MAX_V1":12}}}}
* 输出示例Value_V_A_MAX_V1 -> 12
*
* 数组会直接转成逗号分隔值,这样后续单元格可以拆成多列展示。
*/
static void flattenJson(const json& j, const std::string& prefix,
std::vector<std::pair<std::string, std::string>>& out) {
if (j.is_object()) {
@@ -121,7 +161,20 @@ static void flattenJson(const json& j, const std::string& prefix,
out.push_back({prefix, toStr(j)});
}
// 读取 XMLValue/@name -> 完整元信息desc/DO/DA
/*
* 读取 XMLValue/@name -> 完整元信息列表。
*
* 输入xmlPath通常是下载的 ICD/XML 映射文件。
* 输出unordered_map<name, vector<ValueMeta>>。同一个 name 可能在不同 Topic、
* DataType 或接线方式下出现,所以值是 vector。
*
* 递归 walk 的上下文参数含义:
* wiring 当前 Sequence 推断出的接线方式,默认 common
* topic 最近一个父级 Topic/@name
* dataType 最近一个父级 DataType/@value
* itemName 最近一个父级 Item/@name
* seqValue 最近一个父级 Sequence/@value。
*/
static std::unordered_map<std::string, std::vector<ValueMeta>> loadValueMetas(const fs::path& xmlPath) {
std::unordered_map<std::string, std::vector<ValueMeta>> mp;
tinyxml2::XMLDocument doc;
@@ -148,6 +201,7 @@ static std::unordered_map<std::string, std::vector<ValueMeta>> loadValueMetas(co
std::string nextDataType = dataType;
std::string nextItemName = itemName;
std::string nextSeqValue = seqValue;
// Topic/DataType/Item/Sequence 不是最终数据项,而是给子孙 Value 提供上下文。
if (nodeName == "Topic") {
const char* name = e->Attribute("name");
nextTopic = name ? name : "";
@@ -161,6 +215,7 @@ static std::unordered_map<std::string, std::vector<ValueMeta>> loadValueMetas(co
if (nodeName == "Sequence") {
const char* seqValue = e->Attribute("value");
nextSeqValue = seqValue ? seqValue : "";
// 项目约定7=星形接线112=角形接线;其他 Sequence 保持 common。
if (seqValue && std::string(seqValue) == "7") nextWiring = "star";
else if (seqValue && std::string(seqValue) == "112") nextWiring = "delta";
}
@@ -189,6 +244,7 @@ static std::unordered_map<std::string, std::vector<ValueMeta>> loadValueMetas(co
}
std::smatch m;
// name 中的 %start,end% 表示一个序列模板,后面要和 jsondata 的 [start-end] 对齐。
if (std::regex_match(vm.name, m, seqRe)) {
vm.isSequence = true;
vm.seqPrefixName = m[1].str(); // 例如 MAX_V
@@ -270,7 +326,19 @@ static bool parseSequenceKey(const std::string& key, std::string& groupId, int&
return false;
}
// ✅ 合并序列:但如果 metricName 在 xmlNames 里存在(比如 V1/MAX_V1/G_V1则禁止合并
/*
* 合并 jsondata 中的序列项。
*
* 输入flattenJson() 得到的 key/value 列表,例如:
* Value_V_A_MAX_V_2=...
* Value_V_A_MAX_V_3=...
*
* 输出:合并成 Value_V_A_MAX_V[2-3]=v2,v3。
*
* 保护规则:
* 如果相位后的 metricName 已经是 XML 中定义的真实指标名,例如 V1/MAX_V1/G_V1
* 就不能把它误当成“序列编号 1”合并否则会把普通指标压成数组。
*/
static std::vector<std::pair<std::string, std::string>>
mergeSequences(const std::vector<std::pair<std::string, std::string>>& flat,
const std::unordered_set<std::string>& xmlNames) {
@@ -341,6 +409,15 @@ struct ParsedKey {
std::string baseName; // 去掉 G_/MAX_/MIN_ 后的 name
};
/*
* 从 jsondata key 中提取排序和展示维度。
*
* 输入Value_V_A_MAX_V1、Value_I_T_G_THD 等。
* 输出:
* phase A/B/C/T
* stat 由指标名前缀转换G_=95值MAX_=最大值MIN_=最小值,默认平均值;
* baseName 去掉统计前缀后的基础指标名,用于匹配 XML Value/@name。
*/
static ParsedKey parseForSort(const std::string& key) {
std::string base = key;
auto bpos = base.find('[');
@@ -495,6 +572,7 @@ static bool metaFilterAllowed(const ValueMeta& vm, const MetaFilter& filter) {
return true;
}
// 多个 ValueMeta 都可能同名时用相位、统计类型、Offset、Tot 等特征打分选最像的那一个。
static int scoreMetaForKey(const ValueMeta& vm, const ParsedKey& pk) {
int score = 0;
if (daMatchesPhase(vm.daPath, pk.phase)) score += 20;
@@ -581,6 +659,7 @@ static const ValueMeta* findMetaForKey(
std::string preferred = preferredMetaName(rawMetric, pk);
std::string secondary = secondaryMetaName(rawMetric, pk);
// 第一优先级key 已经是 [start-end] 数组时,匹配 XML 的 %start,end% 序列模板。
int keyStart = 0, keyEnd = 0;
if (parseKeyRange(key, keyStart, keyEnd)) {
std::string seqPreferred = rawMetric.empty() ? preferred : rawMetric;
@@ -590,6 +669,7 @@ static const ValueMeta* findMetaForKey(
if (!seqCandidates.empty()) return bestMeta(seqCandidates, pk);
}
// 第二优先级按指标名精确匹配。G_ 前缀会优先转成 baseName因为 95 值常复用同一 XML 描述。
std::vector<const ValueMeta*> exactCandidates;
collectByName(metas, preferred, wiring, filter, exactCandidates);
if (exactCandidates.empty()) collectByName(metas, secondary, wiring, filter, exactCandidates);
@@ -693,6 +773,13 @@ struct OutItem {
int offset = 0;
};
/*
* 一张输出工作表的筛选条件。
*
* topic/dataType/wiring 决定从 XML ValueMeta 中取哪些项;
* showStatBlocks 决定统计类 Sheet 是否按 95值/平均值/最大值/最小值分段显示;
* frontType 用于 GUI 选择统计(cfg_stat_data)或实时(cfg_3s_data)时裁剪 Sheet。
*/
struct SheetSpec {
std::string name;
std::string topic;
@@ -727,7 +814,7 @@ struct OriginData {
std::vector<OriginRecord> records;
std::vector<OriginHeader> headers;
std::unordered_map<std::string, std::vector<size_t>> pathToRecords;
std::unordered_set<size_t> used;
std::unordered_set<size_t> used; // matchOrigin 命中后标记,最后可输出“已匹配/未匹配”清单。
};
struct OriginMatch {
@@ -754,8 +841,8 @@ struct JsonDataBucket {
struct WorkbookContext {
fs::path xmlPath;
std::unordered_map<std::string, std::vector<ValueMeta>> valueMetas;
std::unordered_set<std::string> xmlNames;
std::unordered_map<std::string, JsonDataBucket> dataByType;
std::unordered_set<std::string> xmlNames; // XML 中存在的原始 name用于避免序列误合并。
std::unordered_map<std::string, JsonDataBucket> dataByType; // DATA_TYPE -> jsondata 桶。
OriginData originData;
bool hasOrigin = false;
std::string frontType;
@@ -831,6 +918,7 @@ static std::vector<json> parseJsonRoots(const std::string& text) {
throw std::runtime_error("origin file contains unexpected content outside JSON object");
}
// origin 文件可能是多个 JSON 对象直接拼接;这里用括号深度扫描出每个完整根对象。
bool inString = false;
bool escape = false;
int depth = 0;
@@ -867,6 +955,7 @@ static void appendOriginRoot(OriginData& data, const fs::path& originPath, int s
std::string reportName = reportNameFromRoot(root, originPath, sourceOrder);
appendOriginHeader(data, originPath, sourceOrder, root, reportName);
// 常见 origin 结构是 { "mms_json": { "path": value } };如果没有 mms_json则直接把根对象当路径表。
const json* mms = &root;
if (root.is_object() && root.contains("mms_json") && root["mms_json"].is_object()) {
mms = &root["mms_json"];
@@ -932,6 +1021,12 @@ static std::string statInstanceSuffix(const std::string& stat) {
return "";
}
/*
* 把统计类型转换到 DO 实例号。
*
* 规则来自原始路径约定:平均/最大/最小/95值分别用 1/2/3/4 替换 DO 头部末尾的 0。
* 例如 xxx0$MX$... + 最大值 -> xxx2$MX$...
*/
static std::string applyStatInstance(const std::string& doPath, const std::string& stat) {
std::string suffix = statInstanceSuffix(stat);
if (suffix.empty()) return doPath;
@@ -947,6 +1042,7 @@ static std::string applyStatInstance(const std::string& doPath, const std::strin
}
static std::string phaseTokenForDa(const std::string& phase, const std::string& wiring) {
// 角形接线的 A/B/C 在 DA 中对应线电压 AB/BC/CA星形对应普通相 A/B/C。
if (wiring == "delta") {
if (phase == "A") return "phsAB";
if (phase == "B") return "phsBC";
@@ -975,6 +1071,7 @@ static std::string makeOriginPath(const std::string& doPath,
const std::string& phase,
const std::string& wiring) {
if (doPath.empty() || daPath.empty()) return "";
// origin 的 MMS 路径由 DO 与具体相位后的 DA 拼接stat 预留给 DO 实例号转换。
return doPath + "$" + concreteDaPath(daPath, phase, wiring);
}
@@ -1039,6 +1136,13 @@ static OriginMatch matchOrigin(OriginData* origin,
OriginMatch match;
if (!origin) return match;
/*
* 匹配步骤:
* 1. 根据 DO/DA/stat/phase/wiring 生成候选 origin 路径;
* 2. 若候选路径是 [start-end] 序列,按 sourceOrder 分组,选覆盖目标区间最多的报告;
* 3. 将选中报告中的连续索引拼成逗号值,并记录 valueIndexes 用于最终值对齐;
* 4. 普通路径则取第一个未使用记录,避免同一路径在多个输出行重复占用。
*/
for (const std::string& path : makeOriginPathCandidates(doPath, daPath, stat, phase, wiring)) {
if (path.empty()) continue;
@@ -1220,6 +1324,7 @@ static bool makeOutItemForWiring(const std::unordered_map<std::string, std::vect
const ValueMeta* vm = findMetaForKey(valueMetas, key, pk, wiring, filter);
if (requireMeta && !vm) return false;
// label 用“去掉统计后缀的 desc + stat”组成例如“电压最大值”。
std::string desc = vm ? vm->desc : "";
std::string descClean = cleanDescSuffix(desc);
std::string attrSort = !descClean.empty() ? descClean : pk.baseName;
@@ -1230,6 +1335,7 @@ static bool makeOutItemForWiring(const std::unordered_map<std::string, std::vect
int keyStart = 0, keyEnd = 0;
if (vm && vm->isSequence && parseKeyRange(key, keyStart, keyEnd)) {
// XML 中 DA 写的是 [%offset] 占位符,表格展示时改成最终可对齐的 [start-end] 范围。
daDisp = rewriteDaRangeForDisplay(daDisp, *vm, keyStart, keyEnd);
}
@@ -1352,6 +1458,7 @@ static OutItem makeOutItemFromMeta(const ValueMeta& vm,
const std::string& sheetWiring,
const std::unordered_map<std::string, std::string>& valueByKey) {
std::string dataKey = dataKeyForMeta(vm, phase);
// lookupDataKeyForMeta 会把 XML Offset 应用到取数 key 上,使最终值和 XML 定义对齐。
auto it = valueByKey.find(lookupDataKeyForMeta(vm, phase));
std::string val = it == valueByKey.end() ? "" : it->second;
@@ -1423,6 +1530,7 @@ static std::vector<OutItem> buildItemsForSheet(const std::unordered_map<std::str
const std::unordered_map<std::string, std::string>& valueByKey) {
std::vector<OutItem> out;
std::unordered_set<std::string> seen;
// 以 XML 定义为主生成行,即使 jsondata 缺值也保留表格行,后续用红色标出缺失。
for (const auto& kv : valueMetas) {
for (const ValueMeta& vm : kv.second) {
if (vm.topic != spec.topic || !specAllowsDataType(spec, vm.dataType)) continue;
@@ -1515,6 +1623,7 @@ static std::vector<std::string> roundOriginValuesForDisplay(const std::vector<st
static std::string compareValueKey(const std::string& value) {
std::string trimmed = trimValue(value);
long double number = 0;
// 数字统一为 6 位小数比较,避免 "1" 与 "1.000000" 被误判不同;非数字则小写比较。
if (parseNumberValue(trimmed, number)) return fixedSixNumber(number);
return lowerValue(trimmed);
}
@@ -1587,6 +1696,7 @@ static ComparePlan comparePlanForItem(const std::string& finalKey, const OutItem
int baseEnd = 0;
if (!parseArrayBaseRange(item.arrayBase, baseStart, baseEnd)) return plan;
int valueOffset = parseOffsetText(item.valueOffset, 0);
// originPickStart/End 表示原始值中真正要拿来和最终值第 1 列比较的数组下标区间。
plan.originPickStart = baseStart + valueOffset;
plan.originPickEnd = baseEnd + valueOffset;
return plan;
@@ -1715,6 +1825,16 @@ static void writeDataWorksheet(std::ofstream& ofs,
std::vector<OriginHeader> visibleOriginHeaders = visibleOriginHeadersForSheet(origin, items, wiring);
const int totalCols = std::max(9 + maxValues, 3 + static_cast<int>(visibleOriginHeaders.size()));
/*
* 每个数据项写两行:
* 第一行“最终值”:来自 jsondata按逗号拆到 值1..值64
* 第二行“原始值”:来自 origin按同一比较区间对齐展示。
* 行颜色:
* MatchGreen 最终值与原始值一致;
* MatchOrange 找到原始值但内容不一致;
* MatchRed 最终值为 null 或没有匹配到原始值;
* OriginPickBlue 标出序列比较时真正对齐起点。
*/
ofs << "<Worksheet ss:Name=\"" << htmlEscape(sheetName) << "\"><Table>\n";
ofs << "<Column ss:Width=\"210\"/><Column ss:Width=\"75\"/><Column ss:Width=\"85\"/><Column ss:Width=\"85\"/>";
ofs << "<Column ss:Width=\"310\"/><Column ss:Width=\"120\"/><Column ss:Width=\"280\"/>";
@@ -2014,6 +2134,7 @@ static void writeExcelWorkbook(const fs::path& outPath,
ofs << "<Style ss:ID=\"OriginPickBlue\" ss:Parent=\"Default\"><Interior ss:Color=\"#CFE2F3\" ss:Pattern=\"Solid\"/></Style>\n";
ofs << "</Styles>\n";
// SheetSpec 是报表目录统计数据和实时数据各自按接线方式、DATA_TYPE 分 Sheet。
std::vector<SheetSpec> sheets = {
{"星形接线", "HISDATA", "01", "star", true, "cfg_stat_data"},
{"角型接线", "HISDATA", "01", "delta", true, "cfg_stat_data"},
@@ -2060,6 +2181,7 @@ static const std::vector<std::string>& statBlocks() {
}
static void writeWorkbookStage(WorkbookContext& ctx, const fs::path& outPath) {
// 每次重写工作簿前清空 used让匹配过程从头计算避免多次刷新后状态累积。
if (ctx.hasOrigin) ctx.originData.used.clear();
OriginData* origin = ctx.hasOrigin ? &ctx.originData : nullptr;
writeExcelWorkbook(outPath, statBlocks(), ctx.valueMetas, ctx.dataByType, origin, ctx.frontType);
@@ -2071,6 +2193,7 @@ static WorkbookContext createWorkbookTemplateFromXml(const fs::path& xmlPath, co
ctx.frontType = frontType;
ctx.valueMetas = loadValueMetas(xmlPath);
ctx.xmlNames.reserve(ctx.valueMetas.size() * 2);
// xmlNames 只保存 XML 原名,用于 mergeSequences 防止 V1/G_V1 被误合并。
for (const auto& kv : ctx.valueMetas) ctx.xmlNames.insert(kv.first);
writeWorkbookStage(ctx, outPath);
return ctx;
@@ -2117,6 +2240,7 @@ static void applyJsonDataToWorkbook(WorkbookContext& ctx, const json& data, cons
auto merged = mergeSequences(flat, ctx.xmlNames);
std::string dataType = dataTypeFromMerged(merged);
JsonDataBucket& bucket = ctx.dataByType[dataType];
// 同一个 DATA_TYPE 多次出现时保留第一份,避免 MQ 重复消息覆盖已生成结果。
if (bucket.hasData) continue;
bucket.headers.clear();
bucket.valueByKey.clear();
@@ -2200,6 +2324,7 @@ bool pqRunParser(const std::string& xmlPath,
fs::path xmlFile = fs::u8path(xmlPath);
fs::path output = outPath.empty() ? fs::path("final_sorted.xls") : fs::u8path(outPath);
// 解析顺序固定:先 XML 建模板,再填 jsondata最后追加 origin 并重写带对比结果的工作簿。
WorkbookContext ctx = createWorkbookTemplateFromXml(xmlFile, output, frontType);
for (const auto& jsonPath : jsonPaths) {
if (!jsonPath.empty()) applyJsonDataToWorkbook(ctx, readJsonDataFile(fs::u8path(jsonPath)), output);
@@ -2218,6 +2343,7 @@ bool pqRunParser(const std::string& xmlPath,
int main(int argc, char** argv) {
try {
if (argc == 1) {
// 无参数启动 GUI带参数则走下面的旧命令行解析模式。
return runPqGuiApp();
}

View File

@@ -23,6 +23,26 @@
#include <unordered_map>
#include <unordered_set>
/*
* mq.cpp
*
* 本文件负责“外部数据输入/输出”:
* - HTTP 请求台账、ICD 列表和 XML 文件下载;
* - 动态加载 RocketMQ DLL初始化生产者/消费者;
* - 发送数据追踪请求;
* - 消费 DATATopic/TraceTopic把 jsondata/origin 落盘到 trace_data
* - RocketMQ 不可用时把待发消息写到 mq_outbox_*.jsonl便于离线排查。
*
* 数据追踪落盘结构:
* runtime\trace_data\process_N\device_x\monitor_y\
* trace_context.json 本次追踪上下文GUI 自动解析依赖它;
* sent_trace_request.json 已发送/待发送追踪请求;
* jsondata_01.txt DATA_TYPE=01 的 DATATopic 数据;
* jsondata_02.txt/03.txt 闪变等其它 DATA_TYPE
* origin_*.txt TraceTopic 原始 MMS/报告数据;
* final_sorted.xls 自动解析生成的工作簿。
*/
using json = nlohmann::json;
namespace fs = std::filesystem;
@@ -39,6 +59,7 @@ fs::path appRootDir() {
fs::path dir = executableDir();
std::wstring name = dir.filename().wstring();
std::transform(name.begin(), name.end(), name.begin(), [](wchar_t ch) { return (wchar_t)towlower(ch); });
// exe 通常在 bin 下运行runtime/source 与 bin 同级,所以从 bin 回到项目根。
if (name == L"bin" && dir.has_parent_path()) return dir.parent_path();
return dir;
}
@@ -97,11 +118,13 @@ bool setContains(const std::unordered_set<std::string>& allowed, const std::stri
std::unordered_set<std::string> parseStatusSet(const std::string& text) {
std::unordered_set<std::string> out;
try {
// 支持 GUI 默认的 JSON 数组写法,例如 "[0]"、"[1,2]"。
json j = json::parse(text);
if (j.is_array()) {
for (const auto& v : j) out.insert(jsonString(json{{"v", v}}, "v"));
}
} catch (...) {
// 也兼容用户手写 "0,1,2" 或带方括号/引号的宽松格式。
std::stringstream ss(text);
std::string part;
while (std::getline(ss, part, ',')) {
@@ -177,6 +200,7 @@ std::string httpRequest(const std::string& url,
if (!connect) throw std::runtime_error("WinHttpConnect 失败");
DWORD flags = (uc.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0;
// 这个项目约定body 为空走 GET不为空走 POST JSON。
const wchar_t* method = body.empty() ? L"GET" : L"POST";
request = WinHttpOpenRequest(connect, method, object.c_str(),
nullptr, WINHTTP_NO_REFERER,
@@ -300,6 +324,12 @@ std::string nowIsoMillis() {
return oss.str();
}
long long nowUnixMillis() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
void appendTextFile(const fs::path& path, const std::string& text) {
fs::create_directories(path.parent_path().empty() ? fs::path(".") : path.parent_path());
std::ofstream ofs(path, std::ios::binary | std::ios::app);
@@ -346,6 +376,7 @@ bool tagMatches(const std::string& expectedExpression, const std::string& actual
while (start <= expr.size()) {
size_t pos = expr.find("||", start);
std::string part = trimCopy(expr.substr(start, pos == std::string::npos ? std::string::npos : pos - start));
// RocketMQ C 接口订阅表达式可能是 "tagA || tagB",消费时也按同样规则过滤。
if (part == "*" || part == actual) return true;
if (pos == std::string::npos) break;
start = pos + 2;
@@ -369,6 +400,7 @@ long long findTimestampValue(const json& value) {
"Value_TIME", "valueTime", "timestamp", "timeStamp", "storeTimestamp", "time", "Time"
};
if (value.is_object()) {
// 不同消息体时间字段命名不统一,因此递归查找一组常见 key。
for (const auto& key : keys) {
auto it = value.find(key);
if (it != value.end()) {
@@ -507,6 +539,7 @@ bool findMonitorIdValue(const json& value, std::string& out) {
}
for (auto it = value.begin(); it != value.end(); ++it) {
if ((it.key() == "messageBody" || it.key() == "body") && it.value().is_string()) {
// RocketMQ 外层 JSON 的 messageBody 常是字符串化 JSON需要再 parse 一次。
std::string embedded = it.value().get<std::string>();
if (looksLikeJsonText(embedded)) {
try {
@@ -663,6 +696,14 @@ struct CSendResult {
long long offset;
};
/*
* RocketMqRuntime 是进程内唯一的 RocketMQ 适配器。
*
* 设计原因:
* 1. RocketMQ C DLL 通过 LoadLibrary 动态加载,运行时可能不存在;
* 2. GUI 会多次点击“初始化 MQ”需要避免重复启动/热重启导致 DLL 崩溃;
* 3. MQ 回调在 SDK 线程触发,必须用互斥锁保护会话状态,再通过 event_ 通知 GUI。
*/
class RocketMqRuntime {
struct DetachedHandles {
CProducer* producer = nullptr;
@@ -681,7 +722,9 @@ class RocketMqRuntime {
std::string xmlPath;
std::string frontType;
std::string guid;
std::unordered_set<std::string> receivedDataTypes;
long long startedAtMs = 0; // 点击“数据追踪”的时间;更早的 MQ 存量消息不得进入本次会话。
std::unordered_set<std::string> receivedDataTypes; // 同一次追踪中每个 DATA_TYPE 只保留一次。
std::unordered_set<std::string> receivedTraceReports; // 同一次追踪中每个 rpt_id 只保留第一条。
long long dataTimestamp = 0;
int originCounter = 0;
};
@@ -700,6 +743,7 @@ public:
DetachedHandles old;
{
std::lock_guard<std::mutex> lock(mu_);
// 如果生产/消费连接参数未变,只刷新配置和回调;避免重复 StartProducer/StartPushConsumer。
bool sameRuntimeConfig = cfg.producer == cfg_.producer &&
cfg.ipport == cfg_.ipport &&
cfg.producerAccessKey == cfg_.producerAccessKey &&
@@ -721,6 +765,7 @@ public:
return true;
}
if (active_ && producer_ && consumer_) {
// 连接参数变了也不在进程内重启底层 SDK提示用户重启工具后生效。
cfg_ = cfg;
event_ = std::move(event);
if (log) {
@@ -785,6 +830,7 @@ public:
SetPushConsumerLogPath(consumer_, mqLogDir.c_str());
SetPushConsumerThreadCount(consumer_, 2);
// 同一个 Topic 可能同时配置统计/实时 Tag这里合并成 RocketMQ 支持的 "A || B" 表达式。
std::map<std::string, std::string> subscriptions;
auto addSubscription = [&](const std::string& topic, const std::string& tag) {
if (topic.empty()) return;
@@ -830,8 +876,10 @@ public:
const PqLedgerMonitor& monitor,
const std::string& frontType,
const std::string& guid) {
const long long startedAtMs = nowUnixMillis();
std::lock_guard<std::mutex> lock(mu_);
cfg_ = cfg;
// 记录当前追踪目标。后续 MQ 回调会用 monitorId 或 guid 把消息路由到对应 TraceSession。
currentTraceDir_ = pqTraceDirectoryFor(cfg, device, monitor);
currentXmlPath_ = pqFindDeviceXmlPath(cfg, device);
currentMonitorId_ = monitor.id;
@@ -842,10 +890,9 @@ public:
dataTimestamp_ = 0;
originCounter_ = 0;
std::string monitorKey = normalizeMonitorId(monitor.id);
auto old = sessions_.find(monitorKey);
if (old != sessions_.end() && !old->second.guid.empty()) {
sessionsByGuid_.erase(normalizeMonitorId(old->second.guid));
}
// 一次按钮点击只允许一个活动会话。否则旧测点后续到达的消息仍会写入旧会话目录。
sessions_.clear();
sessionsByGuid_.clear();
TraceSession& session = sessions_[monitorKey];
session.monitorId = monitor.id;
session.monitorName = monitor.name;
@@ -853,11 +900,14 @@ public:
session.xmlPath = currentXmlPath_;
session.frontType = frontType;
session.guid = guid;
session.startedAtMs = startedAtMs;
session.receivedDataTypes.clear();
session.receivedTraceReports.clear();
session.dataTimestamp = 0;
session.originCounter = 0;
if (!guid.empty()) sessionsByGuid_[normalizeMonitorId(guid)] = monitorKey;
// GUI 自动解析优先读取这个上下文,避免用户手动维护 json/origin/output 路径。
json context = {
{"deviceId", device.id},
{"deviceName", device.name},
@@ -867,6 +917,8 @@ public:
{"monitorName", monitor.name},
{"guid", guid},
{"frontType", frontType},
{"startedAt", nowIsoMillis()},
{"startedAtMs", startedAtMs},
{"xmlPath", currentXmlPath_},
{"jsonDataPath", (dir / "jsondata_01.txt").u8string()},
{"jsonDataPaths", json::array({
@@ -879,10 +931,13 @@ public:
writeTextFile(dir / "trace_context.json", context.dump(2));
}
bool sendTrace(const PqToolConfig& cfg,
const std::string& payload,
std::string& err,
PqLogFn log) {
bool sendPayload(const std::string& topic,
const std::string& tag,
const std::string& messageKey,
const std::string& payload,
const std::string& label,
std::string& err,
PqLogFn log) {
std::lock_guard<std::mutex> lock(mu_);
err.clear();
if (!active_ || !producer_) {
@@ -890,14 +945,14 @@ public:
return false;
}
CMessage* msg = CreateMessage(nonEmptyCStr(cfg.topicLOG, ""));
CMessage* msg = CreateMessage(nonEmptyCStr(topic, ""));
if (!msg) {
err = "CreateMessage 返回空指针";
return false;
}
SetMessageTags(msg, nonEmptyCStr(cfg.tagLOG, ""));
SetMessageKeys(msg, nonEmptyCStr(cfg.keyLOG, ""));
SetMessageTags(msg, nonEmptyCStr(tag, ""));
SetMessageKeys(msg, nonEmptyCStr(messageKey, ""));
SetMessageBody(msg, payload.c_str());
CSendResult result{};
@@ -909,7 +964,7 @@ public:
}
if (log) {
log("RocketMQ 数据追踪消息已发送TopicLOG=" + cfg.topicLOG
log("RocketMQ " + label + "消息已发送Topic=" + topic
+ "MsgId=" + std::string(result.msgId));
}
return true;
@@ -929,6 +984,7 @@ private:
actualMonitor.clear();
reason.clear();
// 优先用消息体中的测点 ID 路由;没有测点 ID 时再用 guid/key 路由。
actualMonitor = monitorIdFromJsonText(body);
std::string normalized = normalizeMonitorId(actualMonitor);
if (!normalized.empty()) {
@@ -967,6 +1023,7 @@ private:
+ " tag=" + tag
+ " key=" + key
+ " bodyLen=" + std::to_string(body.size()) + "\n");
// 先按 Topic/Tag 判断消息类型,再按 monitorId/guid 过滤到当前追踪会话。
std::string dataFrontType = dataFrontTypeForMessageLocked(topic, tag);
bool isData = !dataFrontType.empty();
bool isTrace = topic == cfg_.traceTopic && tagMatches(cfg_.traceTag, tag);
@@ -990,10 +1047,22 @@ private:
+ " topic=" + topic + " tag=" + tag + "\n");
return 0;
}
// PushConsumer 可能在点击后继续投递积压消息。必须在任何去重、落盘和 GUI 事件之前
// 使用 Broker 存储时间做会话边界判断,保证 jsondata 与 origin 都来自本次追踪。
if (storeTs <= 0 || storeTs < session->startedAtMs) {
appendTextFile(runtimeLogPath("mq_messages.log"), nowIsoMillis()
+ " skipped " + (isData ? "DATATopic" : "TraceTopic")
+ " before trace session: storeTs=" + std::to_string(storeTs)
+ ", startedAtMs=" + std::to_string(session->startedAtMs)
+ ", monitor=" + session->monitorId
+ " topic=" + topic + " tag=" + tag + "\n");
return 0;
}
}
if (isData) {
std::string dataType = dataTypeFromJsonText(body);
std::string normalizedType = normalizeDataType(dataType);
// 同一 DATA_TYPE 重复到达时跳过,避免后来的旧数据覆盖已解析文件。
if (!session->receivedDataTypes.insert(normalizedType).second) {
appendTextFile(runtimeLogPath("mq_messages.log"), nowIsoMillis()
+ " skipped DATATopic duplicate DATA_TYPE=" + normalizedType
@@ -1012,10 +1081,22 @@ private:
if (event_) event_({"data", eventTitle("DATATopic", out.u8string()), body, out.u8string(), session->monitorId, session->traceDir});
} else if (isTrace) {
long long bodyTs = timestampFromJsonText(body);
// 如果 TraceTopic 的时间早于已经收到的 DATATopic认为是旧追踪结果避免串入本次追踪。
if (session->dataTimestamp > 0 && bodyTs > 0 && bodyTs < session->dataTimestamp) {
appendTextFile(runtimeLogPath("mq_messages.log"), nowIsoMillis() + " TraceTopic tag=" + tag + " skipped by timestamp\n");
} else {
fs::path out = nextOriginOutputPathLocked(*session, reportNameFromJsonText(body));
std::string reportName = reportNameFromJsonText(body);
std::string reportKey = lowerAscii(trimCopy(reportName));
if (reportKey.empty()) reportKey = "__unknown_report__";
// TopicAsk 的 limit=20 可能返回同一 rpt_id 的多份历史采样;本工具每次点击只取第一份。
if (!session->receivedTraceReports.insert(reportKey).second) {
appendTextFile(runtimeLogPath("mq_messages.log"), nowIsoMillis()
+ " skipped TraceTopic duplicate report=" + reportKey
+ " monitor=" + session->monitorId
+ " topic=" + topic + " tag=" + tag + "\n");
return 0;
}
fs::path out = nextOriginOutputPathLocked(*session, reportName);
writeTextFile(out, body);
appendTextFile(runtimeLogPath("mq_messages.log"), nowIsoMillis() + " TraceTopic tag=" + tag + " -> " + out.u8string() + "\n");
if (event_) event_({"trace", eventTitle("TraceTopic", out.u8string()), body, out.u8string(), session->monitorId, session->traceDir});
@@ -1104,6 +1185,7 @@ private:
bool loadLibraryLocked() {
if (module_) return true;
// 兼容不同部署目录和 DLL 命名LoadLibraryEx 用于带路径 DLL 时顺带搜索其依赖。
std::vector<std::string> candidates = {
"rocketmq-client-cpp.dll",
"rocketmq.dll",
@@ -1328,6 +1410,7 @@ RocketMqRuntime& rocketRuntime() {
PqToolConfig pqDefaultConfig() {
PqToolConfig cfg;
// 这里只覆盖需要根据 exe 位置动态计算的路径,以及 MQ 常用默认参数。
cfg.producer = "TRACE_PRODUCER";
cfg.consumer = "TRACE_CONSUMER";
cfg.dataStatTopic = "MQ_INST_1697676490574298_Bm3DLamM%LN_Topic";
@@ -1350,6 +1433,7 @@ std::string pqTraceDirectoryFor(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor) {
fs::path root = fs::u8path(cfg.traceRootDir.empty() ? "trace_data" : cfg.traceRootDir);
// 路径中同时放名称和 id既方便人读也保证同名测点不互相覆盖。
std::string proc = safePathPart(firstNonEmpty(device.processNo, "1"), "1");
std::string dev = safePathPart(firstNonEmpty(device.name, device.id), "device");
std::string mon = safePathPart(firstNonEmpty(monitor.name, monitor.id), "monitor");
@@ -1372,6 +1456,7 @@ void pqClearTraceRecords(const PqToolConfig& cfg,
std::string pqFindDeviceXmlPath(const PqToolConfig& cfg, const PqLedgerDevice& device) {
if (!device.xmlPath.empty() && fs::exists(fs::u8path(device.xmlPath))) return device.xmlPath;
// 下载 ICD 后会把接口响应缓存到 runtime\json\icd_response.json这里用 devType 反查本地 XML。
fs::path saveDir = fs::u8path(cfg.xmlSaveDir.empty() ? "downloaded_xml" : cfg.xmlSaveDir);
auto existing = [&](const std::string& name) -> std::string {
if (name.empty()) return "";
@@ -1423,6 +1508,7 @@ bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
devices.clear();
firstXmlPath.clear();
// 1. 请求台账frontIp + terminalStatus/runFlag。
json ledgerReq;
ledgerReq["ip"] = cfg.frontIp;
try {
@@ -1438,6 +1524,7 @@ bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
std::unordered_set<std::string> terminalAllowed = parseStatusSet(cfg.terminalStatus);
std::set<std::string> devTypes;
// 2. 解析装置和 monitorData 测点,并按 terminalStatus 过滤装置。
json data = parseResponseDataArray(ledgerRaw, "台账");
for (const auto& item : data) {
PqLedgerDevice dev;
@@ -1479,6 +1566,7 @@ bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
if (log) log("台账解析完成: 装置 " + std::to_string(devices.size()) + "");
// 3. 请求 ICD 列表。icdFlag=1 时只请求本次台账中出现的 devType。
json icdReq = json::array();
if (cfg.icdFlag == "1") {
for (const auto& t : devTypes) icdReq.push_back(t);
@@ -1492,6 +1580,7 @@ bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
json icdData = parseResponseDataArray(icdRaw, "ICD");
int downloaded = 0;
std::unordered_map<std::string, std::string> devTypeToXml;
// 4. 按 filePath 下载 XML保存到 xmlSaveDir并建立 devType -> XML 路径映射。
for (const auto& model : icdData) {
std::string modelId = jsonString(model, "id");
std::string devType = jsonString(model, "devType");
@@ -1524,6 +1613,7 @@ bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
}
bool pqInitializeMq(const PqToolConfig& cfg, PqLogFn log, PqMqEventFn event) {
// 初始化时先把界面配置落盘,便于用户核对,也便于离线排查 MQ 参数。
json saved = {
{"producer", cfg.producer},
{"ipport", cfg.ipport},
@@ -1532,6 +1622,9 @@ bool pqInitializeMq(const PqToolConfig& cfg, PqLogFn log, PqMqEventFn event) {
{"topicLOG", cfg.topicLOG},
{"tagLOG", cfg.tagLOG},
{"keyLOG", cfg.keyLOG},
{"topicAsk", cfg.topicAsk},
{"tagAsk", cfg.tagAsk},
{"keyAsk", cfg.keyAsk},
{"consumer", cfg.consumer},
{"consumerIpport", cfg.consumerIpport},
{"consumerAccessKey", cfg.consumerAccessKey},
@@ -1552,6 +1645,7 @@ bool pqInitializeMq(const PqToolConfig& cfg, PqLogFn log, PqMqEventFn event) {
writeTextFile(runtimeJsonPath("mq_runtime_config.json"), saved.dump(2));
if (log) log("MQ config saved to " + runtimeJsonPath("mq_runtime_config.json").u8string());
if (!rocketRuntime().initialize(cfg, log, event) && log) {
// 这里仍返回 true让 GUI 可以继续构造追踪请求;发送失败时会进入 outbox 兜底。
log("RocketMQ runtime is not active; trace messages will be written to mq_outbox_*.jsonl.");
log("RocketMQ source is present, but a 32-bit rocketmq-client-cpp.dll or rocketmq.dll is still required.");
}
@@ -1571,6 +1665,7 @@ std::string pqBuildTracePayload(const PqToolConfig& cfg,
std::string guid = makeGuid();
int processNo = 0;
if (frontType == "cfg_3s_data") {
// 实时数据进程约定为 0统计数据使用台账中的 processNo。
processNo = 0;
} else {
try {
@@ -1580,6 +1675,7 @@ std::string pqBuildTracePayload(const PqToolConfig& cfg,
}
}
// body 是前置识别的 set_log 指令outer 是 RocketMQ 业务包装。
json body = {
{"code", "set_log"},
{"nodeId", cfg.frontInst},
@@ -1602,6 +1698,26 @@ std::string pqBuildTracePayload(const PqToolConfig& cfg,
return outer.dump();
}
std::string buildRealtimeAskPayload(const PqLedgerDevice& device,
const PqLedgerMonitor& monitor) {
const std::string guid = makeGuid();
json body = {
{"devSeries", device.id},
{"limit", 20},
{"line", monitor.id},
{"realData", true},
{"soeData", true}
};
json outer = {
{"key", guid},
{"source", "WEB"},
{"sendTime", nowIsoMillis()},
{"retryTimes", 0},
{"messageBody", body.dump()}
};
return outer.dump();
}
bool pqSendTraceRequest(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor,
@@ -1610,6 +1726,7 @@ bool pqSendTraceRequest(const PqToolConfig& cfg,
PqMqEventFn event) {
std::string payload = pqBuildTracePayload(cfg, device, monitor, frontType);
std::string guid = guidFromJsonText(payload);
// 先设置追踪目标,确保追踪请求发出后立即到达的 MQ 回调也能正确路由。
rocketRuntime().setTraceTarget(cfg, device, monitor, frontType, guid);
fs::path traceDir = fs::u8path(pqTraceDirectoryFor(cfg, device, monitor));
fs::create_directories(traceDir);
@@ -1617,24 +1734,36 @@ bool pqSendTraceRequest(const PqToolConfig& cfg,
writeTextFile(sentPath, prettyJsonOrRaw(payload));
writeTextFile(runtimeJsonPath("last_trace_request.json"), json::parse(payload).dump(2));
if (event) event({"sent", eventTitle("Send", sentPath.u8string()), payload, sentPath.u8string(), monitor.id, traceDir.u8string()});
std::string mqError;
if (rocketRuntime().sendTrace(cfg, payload, mqError, log)) {
if (log) log("Trace target monitor=" + monitor.name + "(" + monitor.id + "), frontType=" + frontType);
return true;
auto sendOrSave = [&](const std::string& topic,
const std::string& tag,
const std::string& messageKey,
const std::string& body,
const std::string& label) {
std::string mqError;
if (rocketRuntime().sendPayload(topic, tag, messageKey, body, label, mqError, log)) return;
// RocketMQ 未初始化或发送失败时不丢请求,按目标 Topic 追加到对应 outbox。
std::string topicPart = sanitizeFilePart(topic.empty() ? label : topic);
fs::path outbox = runtimeJsonPath("mq_outbox_" + topicPart + ".jsonl");
std::ofstream ofs(outbox, std::ios::binary | std::ios::app);
if (!ofs) throw std::runtime_error("无法写入 MQ outbox: " + outbox.string());
ofs << body << "\n";
if (log) log(label + "消息发送失败(" + mqError + "),已写入 " + outbox.string());
};
// 第一条始终先发到 TopicLOG请求前置建立本次追踪日志。
sendOrSave(cfg.topicLOG, cfg.tagLOG, cfg.keyLOG, payload, "set_log");
// cfg_3s_data 紧接着向 TopicAsk 请求该装置、测点的实时/SOE 数据。
if (frontType == "cfg_3s_data") {
std::string askPayload = buildRealtimeAskPayload(device, monitor);
fs::path askPath = traceDir / "sent_realtime_request.json";
writeTextFile(askPath, prettyJsonOrRaw(askPayload));
writeTextFile(runtimeJsonPath("last_realtime_request.json"), json::parse(askPayload).dump(2));
if (event) event({"sent", eventTitle("Send Ask", askPath.u8string()), askPayload, askPath.u8string(), monitor.id, traceDir.u8string()});
sendOrSave(cfg.topicAsk, cfg.tagAsk, cfg.keyAsk, askPayload, "realtime ask");
}
std::string topicPart = sanitizeFilePart(cfg.topicLOG.empty() ? "TopicLOG" : cfg.topicLOG);
fs::path outbox = runtimeJsonPath("mq_outbox_" + topicPart + ".jsonl");
std::ofstream ofs(outbox, std::ios::binary | std::ios::app);
if (!ofs) throw std::runtime_error("无法写入 MQ outbox: " + outbox.string());
ofs << payload << "\n";
writeTextFile(runtimeJsonPath("last_trace_request.json"), json::parse(payload).dump(2));
if (log) {
log("已构造数据追踪消息TopicLOG=" + cfg.topicLOG + ", TagLOG=" + cfg.tagLOG + ", KeyLOG=" + cfg.keyLOG);
log("测点=" + monitor.name + "(" + monitor.id + "), frontType=" + frontType);
log("当前未直连 RocketMQ消息已写入 " + outbox.string());
}
if (log) log("Trace target monitor=" + monitor.name + "(" + monitor.id + "), frontType=" + frontType);
return true;
}

View File

@@ -1,10 +1,44 @@
#pragma once
/*
* pq_app.h
*
* 这是整个工具的公共边界文件:
* - interface.cpp 负责 Win32 GUI通过这些结构体读取/显示配置和台账。
* - mq.cpp 负责 HTTP 台账/ICD 获取、RocketMQ 初始化和数据追踪落盘。
* - main.cpp 负责 JSON/XML 映射解析,生成 Excel 2003 XML 格式的 .xls。
* - icd_mapper.cpp 负责把外部 ICD 中的 DOI 描述补写到工作簿。
*
* 头文件中只放跨模块共享的数据结构和函数声明,不放 Win32/RocketMQ 的实现细节。
*/
#include <functional>
#include <string>
#include <utility>
#include <vector>
/*
* 工具运行配置。
*
* 输入来源:
* 1. pqDefaultConfig() 给出默认值;
* 2. GUI 中 readConfig() 会用用户界面上的编辑框覆盖默认值;
* 3. 命令行模式直接使用 main.cpp 中传入的 XML/JSON/origin 路径。
*
* 关键字段:
* frontInst/frontIp 前置节点标识和 IP参与台账请求和追踪消息。
* terminalStatus 终端状态过滤条件,支持 JSON 数组字符串,如 "[0]"。
* monitorStatus 预留的测点状态过滤字段。
* icdFlag 为 "1" 时按 devType 请求 ICD 列表,为其他值时请求全量/默认列表。
* dataStatTopic 统计数据 Topic对应 frontType=cfg_stat_data。
* dataRealtimeTopic 实时数据 Topic对应 frontType=cfg_3s_data。
* topicAsk/tagAsk/keyAsk cfg_3s_data 发送 set_log 后的实时数据请求目标。
* dataTopic/dataTag/dataKey GUI 读取 frontType 后会设置成当前活跃数据 Topic/Tag/Key。
* traceRootDir 每次“数据追踪”按进程/装置/测点保存 JSON、origin 和报表的根目录。
*
* 输出影响:
* xmlSaveDir、jsonDataPath、originPaths、outputPath、traceRootDir 控制程序落盘位置。
*/
struct PqToolConfig {
std::string frontInst = "6c2adb324940e19e208d717f4970f2ee";
std::string frontIp = "192.168.1.68";
@@ -24,6 +58,9 @@ struct PqToolConfig {
std::string topicLOG = "ask_log_Topic";
std::string tagLOG = "6c2adb324940e19e208d717f4970f2ee";
std::string keyLOG = "Test_Keys";
std::string topicAsk = "ask_data_Topic";
std::string tagAsk = "6c2adb324940e19e208d717f4970f2ee";
std::string keyAsk = "Test_Keys";
std::string consumer = "TRACE_CONSUMER";
std::string consumerIpport = "192.168.1.68:9876";
@@ -50,6 +87,12 @@ struct PqToolConfig {
std::string traceRootDir = "runtime\\trace_data";
};
/*
* 台账中的单个测点。
*
* 输入pqFetchLedgerAndIcd() 从 WebDevice 接口的 monitorData 数组解析。
* 输出GUI 左侧树显示;数据追踪时 monitor.id 会写入 MQ 追踪消息。
*/
struct PqLedgerMonitor {
std::string id;
std::string name;
@@ -60,6 +103,15 @@ struct PqLedgerMonitor {
std::vector<std::pair<std::string, std::string>> fields;
};
/*
* 台账中的单个装置。
*
* 输入pqFetchLedgerAndIcd() 从 WebDevice 接口的 data 数组解析。
* 输出:
* - GUI 按 processNo -> device -> monitor 组织成树;
* - devType 用于匹配并下载对应 ICD/XML
* - xmlPath 记录当前装置优先使用的 XML 模板。
*/
struct PqLedgerDevice {
std::string id;
std::string name;
@@ -81,8 +133,19 @@ struct PqLedgerDevice {
std::vector<PqLedgerMonitor> monitors;
};
// 日志回调业务函数只发字符串GUI/命令行决定显示到日志框还是控制台。
using PqLogFn = std::function<void(const std::string&)>;
/*
* MQ/追踪过程中抛给 GUI 的事件。
*
* kind:
* sent 已构造并发送/落盘的数据追踪请求;
* data DATATopic 收到的 jsondata 数据;
* trace TraceTopic 收到的 origin/原始 MMS 数据。
*
* path/traceDir 让 GUI 能把事件定位到具体追踪目录并自动刷新解析预览。
*/
struct PqMqEvent {
std::string kind;
std::string title;
@@ -94,44 +157,114 @@ struct PqMqEvent {
using PqMqEventFn = std::function<void(const PqMqEvent&)>;
// 默认配置。mq.cpp 会把运行期路径修正到 exe 所在目录旁的 runtime 目录。
PqToolConfig pqDefaultConfig();
/*
* 获取台账并下载 ICD/XML。
*
* 输入cfg 中的 WebDevice/WebIcd/WebFiledownload、状态过滤和保存目录。
* 输出:
* devices 装置和测点列表;
* firstXmlPath 第一个成功下载的 XML用于自动填充 GUI 的 XML 路径;
* return true 表示接口请求和解析流程完成,异常会抛出到调用方。
*/
bool pqFetchLedgerAndIcd(const PqToolConfig& cfg,
std::vector<PqLedgerDevice>& devices,
std::string& firstXmlPath,
PqLogFn log);
/*
* 初始化 RocketMQ 运行时。
*
* 输入生产者、消费者、Topic、Tag、Key、NameServer 和鉴权配置。
* 输出:运行配置会保存到 runtime\json\mq_runtime_config.json
* 如果 DLL 不可用,函数仍返回 true但后续发送会落到 mq_outbox_*.jsonl。
*/
bool pqInitializeMq(const PqToolConfig& cfg, PqLogFn log, PqMqEventFn event = {});
/*
* 构造数据追踪消息体。
*
* 输入:
* device.processNo 统计数据追踪时参与 processNo实时数据固定 processNo=0。
* monitor.id 写入 messageBody.id代表追踪的测点。
* frontType cfg_stat_data 或 cfg_3s_data。
* 输出:外层 RocketMQ JSON 字符串key 为 guidmessageBody 是内部 JSON 字符串。
*/
std::string pqBuildTracePayload(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor,
const std::string& frontType);
/*
* 发送一次数据追踪请求。
*
* 执行步骤:
* 1. 构造 payload 并在 trace_context.json 中记录本次追踪上下文;
* 2. 写 sent_trace_request.json 和 last_trace_request.json便于复盘
* 3. RocketMQ 可用时先同步发送到 topicLOG/tagLOG/keyLOG
* 4. cfg_3s_data 再发送实时数据请求到 topicAsk/tagAsk/keyAsk
* 5. RocketMQ 不可用时追加到对应 Topic 的 mq_outbox_*.jsonl 作为兜底。
*/
bool pqSendTraceRequest(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor,
const std::string& frontType,
PqLogFn log,
PqMqEventFn event = {});
// 清空某个追踪目录下的 txt/json/jsonl/log 文本记录,保留目录本身。
void pqClearTraceRecords(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor);
// 按 traceRootDir/process_x/device_x/monitor_x 生成稳定追踪目录。
std::string pqTraceDirectoryFor(const PqToolConfig& cfg,
const PqLedgerDevice& device,
const PqLedgerMonitor& monitor);
// 为装置寻找 XML优先 device.xmlPath其次 icd_response.json 下载缓存,最后 cfg.xmlPath。
std::string pqFindDeviceXmlPath(const PqToolConfig& cfg, const PqLedgerDevice& device);
/*
* 解析 XML + 单个 jsondata + 多个 origin生成 Excel XML 工作簿。
* 这是旧调用形式,内部会转成 vector 版本。
*/
bool pqRunParser(const std::string& xmlPath,
const std::string& jsonPath,
const std::vector<std::string>& originPaths,
const std::string& outPath,
std::string* err,
const std::string& frontType = "");
/*
* 解析 XML + 多个 jsondata + 多个 origin生成 Excel XML 工作簿。
*
* 输入:
* xmlPath ICD/XML 映射模板;
* jsonPaths DATATopic 统计/实时数据,可按 DATA_TYPE 分桶;
* originPaths TraceTopic 原始 MMS 数据,用于值对比和未匹配列表;
* frontType 为空表示生成所有工作表cfg_stat_data/cfg_3s_data 表示只生成对应流程。
* 输出:
* outPath Excel 2003 XML 格式的 .xls
* err 失败时写入异常信息;
* return true 成功false 失败。
*/
bool pqRunParser(const std::string& xmlPath,
const std::vector<std::string>& jsonPaths,
const std::vector<std::string>& originPaths,
const std::string& outPath,
std::string* err,
const std::string& frontType = "");
/*
* 把外部 ICD 中 LN/DOI 的 desc 导入到工作簿“未匹配的原始数据”页。
* 通过原始路径前缀匹配 DO 路径,帮助解释没有在当前映射中匹配到的 origin 数据。
*/
bool pqApplyIcdDescriptionsToWorkbook(const std::string& icdPath,
const std::string& workbookPath,
std::string* message = nullptr);
// GUI 入口。main.cpp 无命令行参数时调用,内部创建 Win32 主窗口和消息循环。
int runPqGuiApp();