更新使用手册;修复滚动条问题;新增表映射关系查询

This commit is contained in:
taocong
2026-10-05 10:49:50 +08:00
committed by taocong45644
parent e7030a6dbf
commit 84009be804
26 changed files with 2089 additions and 37 deletions
+8 -1
View File
@@ -20,7 +20,7 @@
namespace {
// V2 默认值(契约 docs/转码配置格式契约.md): core 默认 ucbp, 柜面接口默认否;
// V2 默认值(契约 详细设计文档.md §7.5): core 默认 ucbp, 柜面接口默认否;
// dialect 不落 UI, 保存时由 core 派生(ucbp/uses→mdb, uarg/usms→db)
bool validateLsFunctionNames(QWidget *parent, const QStringList &functionNames)
@@ -355,6 +355,11 @@ bool BatchConvertPage::saveConfig(const QStringList &funcList)
}
// 5. 原子写回(二进制模式: 内容字节原样落盘, 不做任何换行转换)
// 保存窗口: 写入前开启, 窗口期内 QSaveFile 替换目标文件引发的
// fileChanged/directoryChanged 监控事件直接忽略, 防止自身保存触发重载
// (重载会 refreshTable 强制 scrollToTop, 表现为下拉选择后滚动条回顶);
// 失败路径(打开/提交失败)由组件 2s 兜底自动关窗
m_configMonitor->beginSaveWindow();
QSaveFile saveFile(configFilePath());
if (!saveFile.open(QIODevice::WriteOnly)) {
LogManager::instance()->logError(
@@ -368,6 +373,8 @@ bool BatchConvertPage::saveConfig(const QStringList &funcList)
return false;
}
// 程序自身写入后更新监控 mtime 基准, 避免触发无谓的重载
m_configMonitor->endSave();
return true;
}
+1 -1
View File
@@ -14,7 +14,7 @@ class QStandardItem;
// 保存时对原文件做文本级区间替换(保留 cust.json 其余全部格式不重序列化)。
// core 决定目标核心(ucbp/uses/uarg/usms);dialect 由 core 派生、不提供独立 UI
// (ucbp/uses→mdb,uarg/usms→db);viaUmgr 为是否柜面接口。
// 格式契约见 docs/转码配置格式契约.md
// 格式契约见 详细设计文档.md §7.5
class BatchConvertPage : public ConvertWorkbenchBase
{
Q_OBJECT
+23 -6
View File
@@ -63,7 +63,7 @@ namespace {
for (const QJsonValue &v : dirArray) {
// V1 字符串元素 / V2 对象元素(取 name 字段)双格式兼容
// (全量转码 convert.dir 格式升级, 见 docs/转码配置格式契约.md)
// (全量转码 convert.dir 格式升级, 见 详细设计文档.md §7.5)
QString name = v.isObject()
? v.toObject().value(Constants::DirEntry::Name).toString()
: v.toString();
@@ -960,12 +960,29 @@ void CodeSearchPage::onQueryByInput()
return;
}
const QList<FunctionInfo> results = cache->searchUFT3Functions(raw, Constants::FunctionSearch::FieldFunctionNo, true);
if (results.isEmpty() || results.first().eName.isEmpty()) {
QMessageBox::information(this, "提示",
QString("未找到功能号:%1 对应的函数").arg(raw));
return;
if (!results.isEmpty() && !results.first().eName.isEmpty()) {
target = results.first().eName;
} else {
// 元数据查不到时回退代码索引按 objectId 查找: 元数据接口扫描会排除
// cbp/ucbp 目录(moduleGenerationCheck 开启时)且同名中文名接口互相覆盖,
// 部分功能号在元数据中缺失; 代码索引全量覆盖检索目录, 无此限制。
// 后台任务可能正在重建索引(写 objectId 哈希), 主线程读前须确认无在途任务
if (!m_activeWatchers.isEmpty()) {
QMessageBox::information(this, "提示", "当前有任务正在执行, 请稍候再试");
return;
}
const QList<QSharedPointer<const CallChainAnalyzer::FunctionInfo>> byId =
currentAnalyzer()->lookupByObjectId(raw);
if (byId.isEmpty()) {
QMessageBox::information(this, "提示",
QString("未找到功能号:%1 对应的函数\n"
"请确认该功能号属于 UF30 工程接口,且元数据已更新、"
"代码检索路径正确并已刷新索引。").arg(raw));
return;
}
// 同功能号多接口时取第一个候选(均在当前检索目录内, 皆可展示调用链)
target = byId.first()->chineseName;
}
target = results.first().eName;
}
runQuery(target);
+32 -2
View File
@@ -17,6 +17,8 @@
#include <QPainter>
#include <QShowEvent>
#include <QResizeEvent>
#include <QScrollBar>
#include <QTimer>
#include "ElaPushButton.h"
#include "ElaTheme.h"
@@ -196,6 +198,11 @@ QStandardItem *ConvertWorkbenchBase::createNameItem(const QString &funcName)
void ConvertWorkbenchBase::refreshTable()
{
// 记录重建前的滚动像素位置: 编辑/增删/外部重载后恢复原位, 避免全量重建
// 后视图跳顶(曾表现为"修改 UF20 函数名后滚动条自动跳回顶部")
QScrollBar *vbar = m_funcTable->verticalScrollBar();
const int savedScrollPos = vbar->value();
// 不使用 blockSignals:QTableView 依赖 rowsRemoved/rowsInserted 信号更新内部布局缓存,
// 若屏蔽会导致新增行不可见。
// 批量重建期间抑制逐行重绘:N 行的 removeRow/appendRow/setIndexWidget 每步都会触发
@@ -234,11 +241,24 @@ void ConvertWorkbenchBase::refreshTable()
// 恢复视图更新并整体重绘一次
m_funcTable->setUpdatesEnabled(true);
// 视图刷新保险:强制重绘并滚动到顶部,确保新增行可见
// 视图刷新保险:强制重绘;恢复滚动位置。
// 关键时序: 重建后滚动范围(maximum)要等 Qt 延迟布局事件才最终确定, 直接 setValue
// 会被 clamp(表现为位置漂移到中间); scrollTo(PositionAtTop) 按视口相对坐标换算,
// 在布局中间态下会错位甚至因"目标已可见"短路不滚。因此先 doItemsLayout() 强制
// 同步完成延迟布局(滚动范围就绪), 再按绝对像素值精确恢复
m_funcTable->viewport()->update();
m_funcTable->resizeRowsToContents();
m_funcTable->scrollToTop();
m_funcTable->doItemsLayout();
vbar->setValue(savedScrollPos);
m_funcTable->clearSelection();
// 延迟一拍兜底补设: 布局/滚动范围在此后才最终确定的场景下校正,
// 防止其他代码在重建后改动滚动条导致位置漂移
QTimer::singleShot(0, m_funcTable, [vbar, savedScrollPos]() {
if (vbar->value() != savedScrollPos) {
vbar->setValue(savedScrollPos);
}
});
}
void ConvertWorkbenchBase::refreshExistColumn()
@@ -283,6 +303,16 @@ void ConvertWorkbenchBase::showEvent(QShowEvent *event)
// 构造期 loadConfig 计算列宽时页面尚未布局, viewport 宽度非真实值,
// 首次切入本页时宽度已就绪, 重算一次保证列宽比例正确(幂等, 开销可忽略)
applyColumnLayout();
// 行高定稿: 页面在启动期(窗口未显示、字体/控件未 polish)构建, 那时 refreshTable
// 的 resizeRowsToContents 算出的行高偏小(如 28px); 首次编辑触发的重建在可见
// 状态下算出最终行高(如 33px), 行高突变使同一滚动数值下的可视内容整体位移,
// 表现为"首次修改后滚动条回滚"。切页显示后补算一次, 让行高在用户操作前定稿
// (延迟一拍确保布局事件处理完毕后再测量, 测量结果与重建路径一致)
QTimer::singleShot(0, this, [this]() {
if (m_funcTable && isVisible()) {
m_funcTable->resizeRowsToContents();
}
});
// 元数据更新后 DataCache 可能变化: 切页回来仅轻量刷新"是否存在"列,
// 不做全量表格重建(数据变更已由 refreshTable/外部修改监控统一触发)
refreshExistColumn();
+33 -13
View File
@@ -210,6 +210,21 @@ void MappingTablePage::onModelItemChanged(QStandardItem *item)
void MappingTablePage::onSave()
{
QString errorMessage;
const SaveResult result = saveToDisk(&errorMessage);
if (result == SaveResult::Ok) {
QMessageBox::information(this, "提示", "保存成功!");
} else if (result == SaveResult::ValidationFailed) {
QMessageBox::warning(this, "提示", errorMessage);
} else {
QMessageBox::critical(this, "错误", errorMessage);
}
}
MappingTablePage::SaveResult MappingTablePage::saveToDisk(QString *errorMessage)
{
if (errorMessage) errorMessage->clear();
// 开启保存窗口: 窗口期内监控事件直接忽略
m_monitor->beginSaveWindow();
@@ -218,8 +233,8 @@ void MappingTablePage::onSave()
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
m_monitor->endSave(); // 早退必须关闭窗口,否则监控被永久抑制
QMessageBox::critical(this, "错误", "无法读取 cust.json!");
return;
if (errorMessage) *errorMessage = "无法读取 cust.json!";
return SaveResult::Error;
}
const QString rawJson = QString::fromUtf8(file.readAll());
file.close();
@@ -228,21 +243,21 @@ void MappingTablePage::onSave()
QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
m_monitor->endSave();
QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString());
return;
if (errorMessage) *errorMessage = "cust.json 解析失败: " + error.errorString();
return SaveResult::Error;
}
QJsonObject custJson = doc.object();
// 提取原始 key 顺序
const QStringList keyOrder = CustJsonIo::extractTopLevelKeyOrder(rawJson);
// 子类校验并回填数据: 校验失败置 errorMessage, 弹警告不落盘
QString errorMessage;
const QHash<QString, QString> overrideRaw = collectMappingData(custJson, errorMessage);
if (!errorMessage.isEmpty()) {
// 子类校验并回填数据: 校验失败不落盘
QString validateError;
const QHash<QString, QString> overrideRaw = collectMappingData(custJson, validateError);
if (!validateError.isEmpty()) {
m_monitor->endSave();
QMessageBox::warning(this, "提示", errorMessage);
return;
if (errorMessage) *errorMessage = validateError;
return SaveResult::ValidationFailed;
}
// endSave 以保存后的新 mtime 为基准(writeCustJsonOrdered 同步写完才返回),
@@ -254,10 +269,15 @@ void MappingTablePage::onSave()
if (writeOk) {
m_dirty = false; // 保存成功后与磁盘内容一致
LogManager::instance()->logInfo(saveLogText(m_model->rowCount()));
QMessageBox::information(this, "提示", "保存成功!");
} else {
QMessageBox::critical(this, "错误", "保存到 cust.json 失败!");
onSavedToDisk(); // 落盘成功钩子(表替换页覆写: 同步 table_mapping.json)
return SaveResult::Ok;
}
if (errorMessage) *errorMessage = "保存到 cust.json 失败!";
return SaveResult::Error;
}
void MappingTablePage::onSavedToDisk()
{
}
void MappingTablePage::onReloadRequested()
+12
View File
@@ -73,6 +73,18 @@ protected:
// 子类构造体内调用(规避构造期虚函数绑定到基类版本)
void load();
// 保存结果分类: Ok=成功落盘; ValidationFailed=子类校验未过(用户级警告);
// Error=读文件/解析/写盘失败(程序级错误)
enum class SaveResult { Ok, ValidationFailed, Error };
// 落盘保存核心(无任何弹窗): onSave 与「映射查询页双击自动添加保存」共用。
// 失败原因经 errorMessage 出参带回(ValidationFailed=校验文案, Error=IO/解析文案)
SaveResult saveToDisk(QString *errorMessage);
// 落盘成功后的钩子(默认空实现),子类按需覆写
// (表替换页覆写: 把新增映射同步追加到 table_mapping.json)
virtual void onSavedToDisk();
// 虚点:从 custJson 填充模型
virtual void populateModel(const QJsonObject &custJson) = 0;
// 虚点:保存时校验并回填数据到 custJson;返回 overrideRaw(键名→格式化原文);
+265 -1
View File
@@ -1,5 +1,24 @@
#include "tablemappingpage.h"
#include "utils/Constants.h"
#include "src/utils/Constants.h"
#include "src/utils/logmanager.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QStandardItem>
namespace {
// JSON 字符串转义: 表名为普通标识符, 实际仅需处理引号与反斜杠
QString escapeJsonString(const QString &text)
{
QString out = text;
out.replace('\\', "\\\\");
out.replace('"', "\\\"");
return out;
}
} // namespace
TableMappingPage::TableMappingPage(QWidget *parent)
: TwoColumnKVMappingPage(Constants::CustKeys::TableCompare,
@@ -7,3 +26,248 @@ TableMappingPage::TableMappingPage(QWidget *parent)
{
load();
}
void TableMappingPage::onSavedToDisk()
{
syncTableMappingJson();
}
void TableMappingPage::syncTableMappingJson()
{
// 模型当前映射(模型序): 刚落盘的 cust.json 与模型一致, 以其为同步源
QHash<QString, QString> modelMap;
for (int row = 0; row < m_model->rowCount(); ++row) {
QStandardItem *keyItem = m_model->item(row, 0);
if (!keyItem || keyItem->text().trimmed().isEmpty()) {
continue; // 空键行在 collectMappingData 已拦截, 正常不会走到
}
QStandardItem *valItem = m_model->item(row, 1);
modelMap.insert(keyItem->text().trimmed(),
valItem ? valItem->text().trimmed() : QString());
}
const QString jsonPath = Constants::tableMappingJson();
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
LogManager::instance()->logError("表替换同步 - 无法读取 table_mapping.json: " + jsonPath);
return;
}
const QString raw = QString::fromUtf8(file.readAll());
file.close();
QJsonParseError parseErr;
const QJsonDocument doc = QJsonDocument::fromJson(raw.toUtf8(), &parseErr);
if (parseErr.error != QJsonParseError::NoError) {
LogManager::instance()->logError(
QString("表替换同步 - table_mapping.json 解析失败: %1 (偏移 %2)")
.arg(parseErr.errorString()).arg(parseErr.offset));
return;
}
const QJsonObject tmObj = doc.object()[Constants::CustKeys::TableCompare].toObject();
// 文本定位 tableCompareUf2_Uf3 对象的开/闭括号, 成员级解析后按原格式重建,
// 保持文件其余部分(条目顺序/一对多数组/空行风格)原样不动
const QString tmKey = "\"" + Constants::CustKeys::TableCompare + "\"";
const qsizetype keyPos = raw.indexOf(tmKey);
const qsizetype openPos = keyPos < 0 ? -1 : raw.indexOf('{', keyPos + tmKey.size());
if (openPos < 0) {
LogManager::instance()->logError(
"表替换同步 - table_mapping.json 中未找到 tableCompareUf2_Uf3 对象");
return;
}
// 成员描述: key + 原文起止(从 key 引号到 value 结束, 含跨行数组原文)
struct Member {
QString key;
qsizetype start;
qsizetype end;
};
QList<Member> members;
// 花括号/方括号配对跳过字符串字面量, 从开括号后逐成员解析
qsizetype i = openPos + 1;
while (i < raw.size()) {
while (i < raw.size() && raw.at(i).isSpace()) {
++i;
}
if (i >= raw.size() || raw.at(i) == '}') {
break;
}
if (raw.at(i) != '"') {
LogManager::instance()->logError(
QString("表替换同步 - tableCompareUf2_Uf3 成员解析异常(偏移 %1), 已放弃同步").arg(i));
return;
}
const qsizetype memberStart = i;
// 解析 key 字符串(处理 \" 与 \\ 转义, 与 QJsonDocument 解析口径一致)
++i;
QString key;
while (i < raw.size()) {
const QChar ch = raw.at(i++);
if (ch == '\\' && i < raw.size()) {
const QChar esc = raw.at(i++);
if (esc == '"' || esc == '\\') {
key += esc;
} else {
key += '\\';
key += esc;
}
} else if (ch == '"') {
break;
} else {
key += ch;
}
}
while (i < raw.size() && raw.at(i).isSpace()) {
++i;
}
if (i >= raw.size() || raw.at(i) != ':') {
LogManager::instance()->logError(
QString("表替换同步 - 成员 %1 缺少冒号, 已放弃同步").arg(key));
return;
}
++i;
while (i < raw.size() && raw.at(i).isSpace()) {
++i;
}
// 解析 value: 字符串 / 数组 / 对象, 括号配对方括号与花括号都计数
int bracketDepth = 0;
bool valString = false;
const qsizetype valueEnd = [&]() {
while (i < raw.size()) {
const QChar ch = raw.at(i);
if (valString) {
if (ch == '\\') {
i += 2;
continue;
}
if (ch == '"') {
valString = false;
}
++i;
continue;
}
if (ch == '"') {
valString = true;
} else if (ch == '[' || ch == '{') {
++bracketDepth;
} else if (ch == ']' || ch == '}') {
--bracketDepth;
if (bracketDepth < 0) {
return i; // 到达本对象闭括号(value 已结束于上一字符)
}
} else if (ch == ',' && bracketDepth == 0) {
return i;
}
++i;
}
return i;
}();
// memberEnd 回退 value 尾部空白, 确保原文片段以 value 末字符结尾
qsizetype trimmedEnd = valueEnd;
while (trimmedEnd > memberStart && raw.at(trimmedEnd - 1).isSpace()) {
--trimmedEnd;
}
members.append({key, memberStart, trimmedEnd});
i = valueEnd;
if (i < raw.size() && raw.at(i) == ',') {
++i;
}
}
// 缩进/换行探测: 以首个既有成员所在行的行首空白为准
const QString newline = raw.contains("\r\n") ? "\r\n" : "\n";
QString entryIndent(" "); // tm 当前缩进风格兜底
QString closeIndent(" ");
if (!members.isEmpty()) {
const qsizetype lineStart = raw.lastIndexOf('\n', members.first().start) + 1;
entryIndent = raw.mid(lineStart, members.first().start - lineStart);
}
{
const qsizetype closePos = raw.indexOf('}', i);
if (closePos >= 0) {
const qsizetype lineStart = raw.lastIndexOf('\n', closePos) + 1;
closeIndent = raw.mid(lineStart, closePos - lineStart);
}
}
// 逐成员对账: 同 key 时字符串 value 变了才更新; 数组(一对多)一律保留原文;
// 模型中多余的 key 追加到末尾。删除暂不同步(成员不因模型缺失而移除)
QStringList blocks;
QSet<QString> processedKeys;
int updated = 0;
int arraySkipped = 0;
int emptyValueSkipped = 0;
for (const Member &member : members) {
processedKeys.insert(member.key);
const auto modelIt = modelMap.constFind(member.key);
if (modelIt == modelMap.constEnd()) {
blocks.append(raw.mid(member.start, member.end - member.start)); // cust 已删: tm 保留
continue;
}
const QJsonValue tmVal = tmObj.value(member.key);
if (tmVal.isArray()) {
blocks.append(raw.mid(member.start, member.end - member.start)); // 一对多数组不覆盖
++arraySkipped;
continue;
}
const QString newVal = modelIt.value();
if (newVal.isEmpty()) {
blocks.append(raw.mid(member.start, member.end - member.start)); // 空值不同步
++emptyValueSkipped;
continue;
}
if (tmVal.isString() && tmVal.toString() != newVal) {
blocks.append('"' + escapeJsonString(member.key) + "\": \""
+ escapeJsonString(newVal) + '"');
++updated;
} else {
blocks.append(raw.mid(member.start, member.end - member.start));
}
}
int appended = 0;
for (auto it = modelMap.constBegin(); it != modelMap.constEnd(); ++it) {
if (processedKeys.contains(it.key()) || it.value().isEmpty()) {
continue;
}
blocks.append('"' + escapeJsonString(it.key()) + "\": \""
+ escapeJsonString(it.value()) + '"');
++appended;
}
if (appended == 0 && updated == 0) {
return; // 无新增无更新(数组跳过/空值跳过不产生写入)
}
// 重建对象内部: 每成员统一行首缩进, 成员间逗号分隔; 成员原文若跨行(数组),
// 其内部各行已有更大缩进, 保持原样
QStringList indented;
indented.reserve(blocks.size());
for (const QString &block : blocks) {
indented.append(entryIndent + block);
}
const QString interior = newline + indented.join(QString(",") + newline) + newline + closeIndent;
const QString updatedRaw = raw.left(openPos + 1) + interior + raw.mid(raw.indexOf('}', i));
// 写前校验: 拼接结果必须仍是合法 JSON, 防止文本插入破坏文件
QJsonParseError verifyErr;
QJsonDocument::fromJson(updatedRaw.toUtf8(), &verifyErr);
if (verifyErr.error != QJsonParseError::NoError) {
LogManager::instance()->logError(
QString("表替换同步 - 拼接结果校验失败, 已放弃写入: %1").arg(verifyErr.errorString()));
return;
}
QFile outFile(jsonPath);
if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
LogManager::instance()->logError("表替换同步 - 无法写入 table_mapping.json: " + jsonPath);
return;
}
outFile.write(updatedRaw.toUtf8());
outFile.close();
LogManager::instance()->logInfo(
QString("表替换保存: table_mapping.json 同步完成(新增 %1, 更新 %2%3%4)")
.arg(appended).arg(updated)
.arg(arraySkipped > 0 ? QString(", 一对多数组跳过 %1").arg(arraySkipped) : QString())
.arg(emptyValueSkipped > 0 ? QString(", 空值跳过 %1").arg(emptyValueSkipped) : QString()));
}
+10 -1
View File
@@ -5,13 +5,22 @@
// 维护 cust.json 顶层键 tableCompareUf2_Uf3(UF20→UFT3 表替换映射):
// 两列映射表, 支持 新增(弹窗)/双击修改/删除/搜索过滤, 保存按顶层键顺序保序写回;
// 填充/校验/新增弹窗样板由 TwoColumnKVMappingPage 中间基类收编
// 填充/校验/新增弹窗样板由 TwoColumnKVMappingPage 中间基类收编。
// 保存成功后把模型中 table_mapping.json 缺失的新增 key 追加到该文件
// (只增不改: 已有条目含一对多数组一律以 table_mapping.json 为准)
class TableMappingPage : public TwoColumnKVMappingPage
{
Q_OBJECT
public:
explicit TableMappingPage(QWidget *parent = nullptr);
protected:
void onSavedToDisk() override;
private:
// 把模型中 table_mapping.json 缺失的 key 追加到该文件(文本定位插入, 保序保格式)
void syncTableMappingJson();
};
#endif // TABLEMAPPINGPAGE_H
@@ -73,6 +73,35 @@ QHash<QString, QString> TwoColumnKVMappingPage::collectMappingData(QJsonObject &
CustJsonIo::formatOrderedJsonObject(orderedData)}};
}
QString TwoColumnKVMappingPage::appendPairIfAbsent(const QString &key, const QString &value)
{
// 判重口径与 onAddRow/collectMappingData 一致: trimmed 后比较
const QString k = key.trimmed();
if (k.isEmpty()) {
return QString("UF20 %1为空,无法添加").arg(m_entityName);
}
for (int row = 0; row < m_model->rowCount(); ++row) {
if (m_model->item(row, 0) && m_model->item(row, 0)->text().trimmed() == k) {
return QString("UF20 %1 \"%2\" 在表替换中已存在(当前映射: %3),未重复添加")
.arg(m_entityName).arg(k)
.arg(m_model->item(row, 1) ? m_model->item(row, 1)->text() : QString());
}
}
QList<QStandardItem*> rowItems;
rowItems.append(new QStandardItem(k));
rowItems.append(new QStandardItem(value.trimmed()));
m_model->appendRow(rowItems);
// 显式置脏纳入外部文件变更保护, 随后立即落盘保存(自动保存语义,
// 无弹窗: 成功静默, 失败把原因带回给调用页 tip 展示)
m_dirty = true;
QString saveError;
const MappingTablePage::SaveResult result = saveToDisk(&saveError);
if (result != MappingTablePage::SaveResult::Ok) {
return QString("已添加但自动保存失败:%1").arg(saveError);
}
return QString();
}
void TwoColumnKVMappingPage::onAddRow()
{
QDialog dialog(this);
@@ -23,6 +23,12 @@ public:
const QString &entityName,
QWidget *parent = nullptr);
// 追加一对映射(UF20 & 内存表映射查询页双击添加的入口)。
// key 按 trimmed 口径判重: 已存在返回提示文案(含当前映射值,不覆盖)。
// 追加成功后自动落盘保存(无弹窗),失败时返回"已添加但自动保存失败:原因"文案,
// 成功返回空串
QString appendPairIfAbsent(const QString &key, const QString &value);
protected:
void populateModel(const QJsonObject &custJson) override;
QHash<QString, QString> collectMappingData(QJsonObject &custJson,
@@ -0,0 +1,371 @@
#include "tablesearchpage.h"
#include "src/utils/Constants.h"
#include "src/utils/datacache.h"
#include "src/utils/logmanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QHeaderView>
#include <QStandardItem>
#include <QRegularExpression>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <algorithm>
#include "ElaTheme.h"
namespace {
// 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符(与功能查询页口径一致)
// 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar)
const QRegularExpression kWhitespaceRegex(QStringLiteral("\\s+"));
QStringList splitKeywords(const QString& text)
{
QString normalized = text;
normalized.replace(QStringLiteral(","), QStringLiteral(" "))
.replace(QStringLiteral(";"), QStringLiteral(" "))
.replace(',', ' ')
.replace(';', ' ');
return normalized.split(kWhitespaceRegex, Qt::SkipEmptyParts);
}
}
TableSearchPage::TableSearchPage(QWidget *parent)
: QWidget(parent)
, m_tipLabel(nullptr)
, m_exactMatch(false)
{
initUI();
}
void TableSearchPage::initUI()
{
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setContentsMargins(30, 30, 30, 30);
layout->setSpacing(20);
QHBoxLayout *searchLayout = new QHBoxLayout;
searchLayout->setSpacing(10);
m_searchEdit = new ElaSearchEdit;
m_searchEdit->setPlaceholderText("搜索 UF20 表名或内存表名...");
m_searchEdit->setSearchEditSize(330, 35);
connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &TableSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &TableSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &TableSearchPage::onExactMatchToggled);
searchLayout->addWidget(m_searchEdit);
searchLayout->addStretch();
layout->addLayout(searchLayout);
m_tableModel = new QStandardItemModel(this);
m_tableModel->setColumnCount(4);
m_tableModel->setHorizontalHeaderLabels({"UF20表名", "UF20表中文名", "内存表名", "内存表中文名"});
m_resultTable = new ElaTableView;
m_resultTable->setModel(m_tableModel);
m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
m_resultTable->horizontalHeader()->setDefaultAlignment(Qt::AlignCenter); // 表头居中
m_resultTable->setMinimumHeight(400);
m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
layout->addWidget(m_resultTable);
// 双击记录行 → 添加映射到「数据映射 → 表替换」页(经 MainWindow 接线)
connect(m_resultTable, &QTableView::doubleClicked, this, &TableSearchPage::onDoubleClicked);
QHBoxLayout *tipLayout = new QHBoxLayout;
m_tipLabel = new QLabel("💡 输入 UF20 表名或内存表名进行查询,支持精确/模糊匹配;双击记录行可添加到「数据映射 → 表替换」");
// 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行,
// 与功能查询页保持一致, 提示单行显示
m_tipLabel->setWordWrap(false);
tipLayout->addWidget(m_tipLabel);
tipLayout->addStretch();
layout->addLayout(tipLayout);
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &TableSearchPage::updateTipStyle);
updateTipStyle();
}
void TableSearchPage::updateTipStyle()
{
if (!m_tipLabel) {
return;
}
const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode();
const QString textColor = ElaThemeColor(mode, BasicText).name();
m_tipLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor));
}
void TableSearchPage::onExactMatchToggled(bool checked)
{
m_exactMatch = checked;
}
void TableSearchPage::onDoubleClicked(const QModelIndex& index)
{
// 双击行取第 1 列(UF20表名)与第 3 列(内存表名): 任一为空即无完整映射关系, 不能添加
const QString uf20Name = m_tableModel->item(index.row(), 0)
? m_tableModel->item(index.row(), 0)->text() : QString();
const QString memName = m_tableModel->item(index.row(), 2)
? m_tableModel->item(index.row(), 2)->text() : QString();
if (uf20Name.isEmpty() || memName.isEmpty()) {
if (m_tipLabel) {
m_tipLabel->setText("⚠️ 该行无完整映射关系(UF20 表名或内存表名为空),无法添加到表替换");
}
return;
}
emit requestAddMapping(uf20Name, memName);
}
void TableSearchPage::onMappingAddResult(const QString& error)
{
// 双击添加的回调结果: error 为空即添加并自动保存成功, 非空为原因(已存在/保存失败等)
if (!m_tipLabel) {
return;
}
if (error.isEmpty()) {
m_tipLabel->setText("✅ 已添加到「数据映射 → 表替换」并自动保存");
} else {
m_tipLabel->setText("⚠️ " + error);
}
}
QList<QPair<QString, QString>> TableSearchPage::loadTableMapping(bool* readOk,
QString* errorOut) const
{
if (readOk) *readOk = false;
if (errorOut) errorOut->clear();
QList<QPair<QString, QString>> pairs;
const QString jsonPath = Constants::tableMappingJson();
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
const QString msg = "无法读取映射文件 table_mapping.json(" + jsonPath + ")";
LogManager::instance()->logError("表查询 - " + msg);
if (errorOut) *errorOut = msg;
return pairs;
}
const QByteArray raw = file.readAll();
file.close();
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
if (err.error != QJsonParseError::NoError) {
// 字符偏移转行号(从 1 起), 便于手工维护文件时定位(常见手误: 末项尾随逗号)
int line = 1;
for (qint64 pos = 0; pos < err.offset && pos < raw.size(); ++pos) {
if (raw.at(pos) == '\n') {
++line;
}
}
const QString msg = QString("映射文件 table_mapping.json 解析失败(第 %1 行附近):%2。"
"映射关系暂不可用,请检查该文件(常见原因:最后一项后多了逗号、引号缺失)")
.arg(line).arg(err.errorString());
LogManager::instance()->logError("表查询 - " + msg);
if (errorOut) *errorOut = msg;
return pairs;
}
// 顶层键 tableCompareUf2_Uf3: key=UF20表名, value=内存表名;
// value 可为字符串(一对一)或数组(一对多, 拆为多条映射对, 展示时一个 UF20 表对应多行)
const QJsonObject mapping = doc.object()[Constants::CustKeys::TableCompare].toObject();
for (auto it = mapping.constBegin(); it != mapping.constEnd(); ++it) {
if (it.value().isArray()) {
const QJsonArray arr = it.value().toArray();
for (const QJsonValue& v : arr) {
pairs.append({it.key(), v.toString()});
}
} else {
pairs.append({it.key(), it.value().toString()});
}
}
if (readOk) *readOk = true;
return pairs;
}
void TableSearchPage::onSearch()
{
// 多关键词查询: 支持逗号/空格等分隔多个表名, 任一关键词命中即展示(OR 并集)
const QStringList keywords = splitKeywords(m_searchEdit->text());
m_tableModel->removeRows(0, m_tableModel->rowCount());
if (keywords.isEmpty()) {
// 空关键字: 页内 tip 反馈, 不弹模态框打断操作
if (m_tipLabel) {
m_tipLabel->setText("请输入 UF20 表名或内存表名");
}
return;
}
// 数据源三件套: 双库"表名→中文名"映射 + table_mapping.json 表映射(key=UF20表名, value=内存表名)。
// 双库映射由 DataCache 内存缓存, 重复查询零开销
const QMap<QString, QString> uf20Names = DataCache::instance()->getUf20TableNameMap();
const QMap<QString, QString> memNames = DataCache::instance()->getUft3TableNameMap();
if (uf20Names.isEmpty() && memNames.isEmpty()) {
if (m_tipLabel) {
m_tipLabel->setText("⚠️ 缓存库中暂无表数据,请先在「元数据更新」页面执行更新");
}
return;
}
bool mappingReadOk = false;
QString mappingError;
const QList<QPair<QString, QString>> mapping = loadTableMapping(&mappingReadOk, &mappingError);
// 映射文件是本页核心数据源: 读取/解析失败时醒目报错并停止检索,
// 不再降级展示单侧结果(否则表现为"有表名却查不到映射", 极具误导性)
if (!mappingReadOk) {
if (m_tipLabel) {
m_tipLabel->setText("⚠️ " + mappingError);
}
return;
}
// 单行结果: 四列与页面列定义一致, 任一表名侧为空即"无映射关系"行
struct TableRow {
QString uf20Name, uf20Cname, memName, memCname;
};
QList<TableRow> rows;
// 已覆盖表名集合: 映射整体行里的表不再补单侧行, 防止同一表重复出现
QSet<QString> coveredUf20;
QSet<QString> coveredMem;
// 已输出的映射对(以换行分隔拼接): 多关键词可能重复命中同一映射对, 去重
QSet<QString> seenPairs;
// 映射命中(key 或 value)→ 完整行: 按 key/value 分别取双库中文名
auto appendFullRow = [&](const QString& key, const QString& value) {
const QString pairKey = key + QLatin1Char('\n') + value;
if (seenPairs.contains(pairKey)) {
return;
}
seenPairs.insert(pairKey);
rows.append({key, uf20Names.value(key), value, memNames.value(value)});
coveredUf20.insert(key);
coveredMem.insert(value);
};
QStringList noMatchKeywords;
for (const QString& keyword : keywords) {
bool hit = false;
if (m_exactMatch) {
// 精确匹配: 先查映射(key 或 value 全等 → 整行), 再查双库(单侧行);
// 映射已覆盖的表不再补单侧行, 否则同一表会多出一条映射列置空的重复记录
for (const auto& pair : mapping) {
if (pair.first == keyword || pair.second == keyword) {
appendFullRow(pair.first, pair.second);
hit = true;
}
}
if (!coveredUf20.contains(keyword) && uf20Names.contains(keyword)) {
rows.append({keyword, uf20Names.value(keyword), QString(), QString()});
coveredUf20.insert(keyword);
hit = true;
}
if (!coveredMem.contains(keyword) && memNames.contains(keyword)) {
rows.append({QString(), QString(), keyword, memNames.value(keyword)});
coveredMem.insert(keyword);
hit = true;
}
} else {
// 模糊匹配: 包含即命中(不区分大小写); 映射命中→整行, 库命中→单侧行
for (const auto& pair : mapping) {
if (pair.first.contains(keyword, Qt::CaseInsensitive) ||
pair.second.contains(keyword, Qt::CaseInsensitive)) {
appendFullRow(pair.first, pair.second);
hit = true;
}
}
for (auto it = uf20Names.constBegin(); it != uf20Names.constEnd(); ++it) {
if (!coveredUf20.contains(it.key()) &&
it.key().contains(keyword, Qt::CaseInsensitive)) {
rows.append({it.key(), it.value(), QString(), QString()});
coveredUf20.insert(it.key());
hit = true;
}
}
for (auto it = memNames.constBegin(); it != memNames.constEnd(); ++it) {
if (!coveredMem.contains(it.key()) &&
it.key().contains(keyword, Qt::CaseInsensitive)) {
rows.append({QString(), QString(), it.key(), it.value()});
coveredMem.insert(it.key());
hit = true;
}
}
}
if (!hit) {
noMatchKeywords.append(keyword);
}
}
if (rows.isEmpty()) {
if (m_tipLabel) {
m_tipLabel->setText("⚠️ 未找到匹配的表,请确认表名是否正确(UF20 表与内存表为两套命名)");
}
return;
}
// 无映射行数: UF20 表名或内存表名任一侧为空即视为无映射关系;
// 中文名缺失: 表名侧有值但缓存库查不到该表(元数据更新未覆盖其来源工程),
// 中文名无从取得, 只能留空并在 tip 中提示数量
int unmappedCount = 0;
int cnameMissingCount = 0;
for (const TableRow& row : rows) {
if (row.uf20Name.isEmpty() || row.memName.isEmpty()) {
++unmappedCount;
}
if ((!row.uf20Name.isEmpty() && row.uf20Cname.isEmpty()) ||
(!row.memName.isEmpty() && row.memCname.isEmpty())) {
++cnameMissingCount;
}
}
// 查询成功: tip 显示统计(一对多映射会产生多条记录); 映射读取失败时附加降级说明
if (m_tipLabel) {
QString tip = QString("已查询 %1 条记录").arg(rows.size());
if (unmappedCount > 0) {
tip += QString("(其中 %1 个无映射关系").arg(unmappedCount);
if (cnameMissingCount > 0) {
tip += QString(",%1 个表中文名缺失(未在缓存库收录)").arg(cnameMissingCount);
}
tip += ")";
} else if (cnameMissingCount > 0) {
tip += QString("(%1 个表中文名缺失,未在缓存库收录)").arg(cnameMissingCount);
}
if (!noMatchKeywords.isEmpty()) {
tip += QString(",%1 个关键词无匹配").arg(noMatchKeywords.size());
}
m_tipLabel->setText(tip);
}
// 结果按 UF20 表名排序(不区分大小写); 仅内存表命中的行(UF20 名为空)排末尾;
// 同一 UF20 表的一对多记录按内存表名排序, 保证相邻展示
std::sort(rows.begin(), rows.end(), [](const TableRow& a, const TableRow& b) {
if (a.uf20Name.isEmpty() != b.uf20Name.isEmpty()) {
return b.uf20Name.isEmpty(); // a 的 UF20 名非空在前
}
const QString an = a.uf20Name.isEmpty() ? a.memName : a.uf20Name;
const QString bn = b.uf20Name.isEmpty() ? b.memName : b.uf20Name;
const int primary = an.compare(bn, Qt::CaseInsensitive);
if (primary != 0) {
return primary < 0;
}
return a.memName.compare(b.memName, Qt::CaseInsensitive) < 0;
});
// 批量填充期间抑制逐行重绘(与功能查询页一致): 关闭更新后仅在填充完成时整体呈现
m_resultTable->setUpdatesEnabled(false);
for (const TableRow& row : rows) {
const QString cells[4] = {row.uf20Name, row.uf20Cname, row.memName, row.memCname};
QList<QStandardItem*> rowItems;
for (const QString& cell : cells) {
QStandardItem *item = new QStandardItem(cell);
item->setFlags(item->flags() & ~Qt::ItemIsEditable);
item->setTextAlignment(Qt::AlignCenter); // 列内容居中
rowItems.append(item);
}
m_tableModel->appendRow(rowItems);
}
m_resultTable->setUpdatesEnabled(true);
}
@@ -0,0 +1,57 @@
#ifndef TABLESEARCHPAGE_H
#define TABLESEARCHPAGE_H
#include <QWidget>
#include <QStandardItemModel>
#include "components/elasearchedit.h"
#include "ElaTableView.h"
class QLabel;
// UF20 & 内存表映射查询页(原"表查询"): 检索 UF20(2.0) 表与内存表(UFT3)之间的映射关系。
// 结果四列: UF20表名 / UF20表中文名(uf20_cache.db 按 key 取) /
// 内存表名(table_mapping.json 映射的 value) / 内存表中文名(uft3_cache.db 按 value 取)。
// 输入 UF20 表名或内存表名均可检索, 支持精确/模糊(ElaSearchEdit 内置开关)两种模式;
// 无映射命中的表仅展示数据库侧字段, 映射相关列置空。
// 双击有完整映射的记录行可添加到「数据映射 → 表替换」页(经 MainWindow 接线, 追加后自动落盘)。
// 不继承 FunctionSearchPageBase: 结果结构为 4 列表信息, 与 FunctionInfo(3 列函数信息)
// 结构不同, 数据源与交互(无批量加入)亦不同, 强行复用基类不值
class TableSearchPage : public QWidget
{
Q_OBJECT
public:
explicit TableSearchPage(QWidget *parent = nullptr);
// 双击添加的回调结果(经 MainWindow 接线后回传): error 为空即添加成功, 非空为原因
void onMappingAddResult(const QString& error);
signals:
// 双击有完整映射的记录行 → 请求把 (UF20表名, 内存表名) 添加到表替换页
void requestAddMapping(const QString& uf20Name, const QString& memName);
private slots:
void onSearch();
void onExactMatchToggled(bool checked);
// 双击记录行: 有完整映射发出 requestAddMapping, 单侧行仅 tip 提示
void onDoubleClicked(const QModelIndex& index);
// tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随)
void updateTipStyle();
private:
void initUI();
// 读取表映射文件(Constants::tableMappingJson(), 即 bin/uf2touft3/config/table_mapping.json,
// 顶层键 tableCompareUf2_Uf3: key=UF20表名, value=内存表名; value 可为字符串(一对一)
// 或字符串数组(一对多, 拆为多条映射对))。
// readOk 出参: 文件读取/解析成功为 true(映射本身可为空), 失败为 false;
// errorOut 出参: 失败时带回可展示的错误原因(含行号, 常见手误如末项尾随逗号)
QList<QPair<QString, QString>> loadTableMapping(bool* readOk,
QString* errorOut = nullptr) const;
ElaSearchEdit *m_searchEdit;
ElaTableView *m_resultTable;
QStandardItemModel *m_tableModel;
QLabel *m_tipLabel;
bool m_exactMatch;
};
#endif
+1 -1
View File
@@ -16,7 +16,7 @@ class QStandardItem;
// 调用契约:仅携带 -m 2(按需转码模式);函数清单与递归开关由引擎直接读取 cust_nonls.json,
// 不再需要 -t/-o 等命令行参数
// 历史格式兼容:旧版条目为数值(无 dialect)按"否/mdb"兜底;更早期字符串数组(无递归
// 标志)统一按"否/mdb"兜底。格式契约见 docs/转码配置格式契约.md
// 标志)统一按"否/mdb"兜底。格式契约见 详细设计文档.md §7.5
class NonLsConvertPage : public ConvertWorkbenchBase
{
Q_OBJECT