优化了xml修改和表格逆生成功能
This commit is contained in:
@@ -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 key:lnClass + 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;
|
||||
|
||||
@@ -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; // 预览表编辑后置 true,XML 预览/搜索前会重新生成。
|
||||
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);
|
||||
|
||||
166
source/main.cpp
166
source/main.cpp
@@ -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 的 jsondata,flattenJson() 压平成
|
||||
* 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/@DO,IEC 61850 MMS 对象路径前半段。
|
||||
std::string daPath; // Value/@DA,IEC 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)});
|
||||
}
|
||||
|
||||
// 读取 XML:Value/@name -> 完整元信息(desc/DO/DA)
|
||||
/*
|
||||
* 读取 XML:Value/@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();
|
||||
}
|
||||
|
||||
|
||||
191
source/mq.cpp
191
source/mq.cpp
@@ -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;
|
||||
}
|
||||
|
||||
133
source/pq_app.h
133
source/pq_app.h
@@ -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 为 guid,messageBody 是内部 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();
|
||||
|
||||
Reference in New Issue
Block a user