#include "batchconvertpage.h" #include "src/utils/Constants.h" #include "src/utils/logmanager.h" #include "src/utils/custjsonio.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include "ElaComboBox.h" #include "ElaTheme.h" namespace { // V2 默认值(契约 详细设计文档.md §7.5): core 默认 ucbp, 柜面接口默认否; // dialect 不落 UI, 保存时由 core 派生(ucbp/uses→mdb, uarg/usms→db) bool validateLsFunctionNames(QWidget *parent, const QStringList &functionNames) { // 只允许 LS_ 开头的功能进入全量转码清单 QStringList invalidNames; for (const QString &name : functionNames) { if (!name.startsWith("LS_")) { invalidNames.append(name); } } if (!invalidNames.isEmpty()) { QMessageBox::warning( parent, QStringLiteral("全量转码"), QStringLiteral("以下功能不是 LS_ 开头,无法加入全量转码清单:\n") + invalidNames.join("\n"), QMessageBox::Ok); LogManager::instance()->logWarning( QString("全量转码 - 存在非 LS_ 前缀功能名被拦截: %1") .arg(invalidNames.join(", "))); return false; } return true; } // 清单表格列定义 enum TableCol { ColName = 0, // UF20函数名 ColExists = 1, // 是否存在 ColCore = 2, // 核心(ucbp/uses/uarg/usms) ColViaUmgr = 3 // 是否柜面接口 }; // 核心取值表(顺序即下拉顺序, 首项为默认值) const QStringList kCoreList = {Constants::DirEntry::CoreUcbp, Constants::DirEntry::CoreUses, Constants::DirEntry::CoreUarg, Constants::DirEntry::CoreUsms}; int coreIndexOf(const QString &core) { const int index = kCoreList.indexOf(core); return index >= 0 ? index : 0; // 未知核心回退默认首项(ucbp) } } // namespace BatchConvertPage::BatchConvertPage(QWidget *parent) : ConvertWorkbenchBase(parent) { // 两阶段初始化: 构造体内调用, 确保界面钩子(loadConfig/applyPageLayout 等)派发到本类 initialize(); } // --------------------------------------------------------------------------- // 界面骨架 // --------------------------------------------------------------------------- void BatchConvertPage::applyPageLayout(QVBoxLayout *layout) { // 页首说明条: 无"操作"列, 双击函数名所在行即可修改函数名 m_tipLabel = new QLabel( "说明:双击函数名所在行,可在弹窗中修改函数名。"); m_tipLabel->setTextFormat(Qt::RichText); m_tipLabel->setWordWrap(true); layout->addWidget(m_tipLabel); } void BatchConvertPage::applyTipStyle(const QString &textColor) { if (m_tipLabel) { m_tipLabel->setStyleSheet(QString("color: %1; font-size: 12px;").arg(textColor)); } } int BatchConvertPage::tableColumnCount() const { return 4; } QStringList BatchConvertPage::tableHeaderLabels() const { return {QStringLiteral("UF20函数名"), QStringLiteral("是否存在"), QStringLiteral("核心"), QStringLiteral("是否柜面接口")}; } void BatchConvertPage::applyColumnLayout() { // 4 列按 8:2:2:2 比例分摊、恰好铺满表格: // "核心"/"是否柜面接口"各占 2 份, 保证四/六字表头与行内下拉完整显示不截断; // 取整余数并入函数名列, 确保总宽精确铺满、无横向滚动条 const int totalWidth = m_funcTable->viewport()->width(); const int unit = totalWidth / 14; m_funcTable->setColumnWidth(ColName, totalWidth - unit * 6); m_funcTable->setColumnWidth(ColExists, unit * 2); m_funcTable->setColumnWidth(ColCore, unit * 2); m_funcTable->setColumnWidth(ColViaUmgr, unit * 2); } // --------------------------------------------------------------------------- // 名称校验 // --------------------------------------------------------------------------- bool BatchConvertPage::validateFunctionNames(const QStringList &names) { return validateLsFunctionNames(this, names); } // --------------------------------------------------------------------------- // 表格行: 核心/柜面接口两列 // --------------------------------------------------------------------------- void BatchConvertPage::attachRowItems(QList &rowItems, const QString &funcName) { Q_UNUSED(funcName) // "核心"/"是否柜面接口"以行内下拉呈现, 占位列仅承载 setIndexWidget 控件 for (int i = 0; i < 2; ++i) { QStandardItem *item = new QStandardItem(""); item->setFlags(item->flags() & ~Qt::ItemIsEditable); item->setTextAlignment(Qt::AlignCenter); rowItems.append(item); } } void BatchConvertPage::attachRowWidgets(int row, const QString &funcName) { // "核心"下拉: ucbp/uses/uarg/usms, 切换即写回并持久化(dialect 由 core 派生, 保存时写入) ElaComboBox *coreCombo = new ElaComboBox; for (const QString &core : kCoreList) { coreCombo->addItem(core); } const QString curCore = m_coreMap.value(funcName, Constants::DirEntry::CoreUcbp); coreCombo->setCurrentIndex(coreIndexOf(curCore)); connect(coreCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this, funcName, coreCombo](int index) { const QString core = kCoreList.value(index, Constants::DirEntry::CoreUcbp); const QString prev = m_coreMap.value(funcName, Constants::DirEntry::CoreUcbp); if (prev == core) { return; } m_coreMap.insert(funcName, core); // 值变化才落盘, 不重建表格; 失败回滚内存与下拉选中项 // (否则界面显示新值而磁盘仍是旧值, 重启后配置与界面矛盾) if (!saveConfig(m_funcList)) { m_coreMap.insert(funcName, prev); coreCombo->blockSignals(true); coreCombo->setCurrentIndex(coreIndexOf(prev)); coreCombo->blockSignals(false); } }); m_funcTable->setIndexWidget(m_tableModel->index(row, ColCore), coreCombo); // "是否柜面接口"下拉: 否/是(与 viaUmgr 布尔一致), 交互与"核心"列一致 ElaComboBox *umgrCombo = new ElaComboBox; umgrCombo->addItem(QStringLiteral("否")); umgrCombo->addItem(QStringLiteral("是")); umgrCombo->setCurrentIndex(m_viaUmgrMap.value(funcName, false) ? 1 : 0); connect(umgrCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this, funcName, umgrCombo](int index) { const bool viaUmgr = (index == 1); const bool prev = m_viaUmgrMap.value(funcName, false); if (prev == viaUmgr) { return; } m_viaUmgrMap.insert(funcName, viaUmgr); if (!saveConfig(m_funcList)) { m_viaUmgrMap.insert(funcName, prev); umgrCombo->blockSignals(true); umgrCombo->setCurrentIndex(prev ? 1 : 0); umgrCombo->blockSignals(false); } }); m_funcTable->setIndexWidget(m_tableModel->index(row, ColViaUmgr), umgrCombo); } // --------------------------------------------------------------------------- // 附加属性映射维护 // --------------------------------------------------------------------------- void BatchConvertPage::onFunctionAdded(const QString &name) { m_coreMap.insert(name, Constants::DirEntry::CoreUcbp); // 新增功能默认 ucbp m_viaUmgrMap.insert(name, false); // 新增功能默认非柜面接口 } void BatchConvertPage::onFunctionsRemoved(const QStringList &names) { for (const QString &name : names) { m_coreMap.remove(name); m_viaUmgrMap.remove(name); } } void BatchConvertPage::onFunctionsRenamed(const QString &oldName, const QString &newName) { const QString core = m_coreMap.value(oldName, Constants::DirEntry::CoreUcbp); const bool viaUmgr = m_viaUmgrMap.value(oldName, false); m_coreMap.remove(oldName); m_viaUmgrMap.remove(oldName); m_coreMap.insert(newName, core); m_viaUmgrMap.insert(newName, viaUmgr); } void BatchConvertPage::onFunctionListCleared() { m_coreMap.clear(); m_viaUmgrMap.clear(); } // --------------------------------------------------------------------------- // 持久化: cust.json // --------------------------------------------------------------------------- bool BatchConvertPage::saveConfig(const QStringList &funcList) { // 1. 读原文件并解析 QFile file(configFilePath()); QByteArray original; if (!file.open(QIODevice::ReadOnly)) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,无法打开原文件: %1").arg(configFilePath())); return false; } original = file.readAll(); file.close(); QJsonParseError parseError; QJsonDocument doc = QJsonDocument::fromJson(original, &parseError); if (parseError.error != QJsonParseError::NoError) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,原文件 JSON 解析失败: %1") .arg(parseError.errorString())); return false; } // 2. 定位 convert.dir 的 [ ] 区间 QString content = QString::fromUtf8(original); QRegularExpression dirRe( QStringLiteral("\"%1\"\\s*:\\s*\\[").arg(Constants::CustKeys::ConvertDirSub)); QRegularExpressionMatch dirMatch = dirRe.match(content); if (!dirMatch.hasMatch()) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,未找到 convert.%1 节点").arg( Constants::CustKeys::ConvertDirSub)); return false; } const int bracketStart = dirMatch.capturedEnd() - 1; // '[' 位置 int bracketEnd = -1; int depth = 0; bool inString = false; bool escaped = false; for (int i = bracketStart; i < content.size(); ++i) { const QChar c = content[i]; if (inString) { if (escaped) { escaped = false; } else if (c == '\\') { escaped = true; } else if (c == '"') { inString = false; } continue; } if (c == '"') { inString = true; } else if (c == '[') { ++depth; } else if (c == ']') { --depth; if (depth == 0) { bracketEnd = i; break; } } } if (bracketEnd < 0) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,convert.%1 数组括号不匹配").arg( Constants::CustKeys::ConvertDirSub)); return false; } // 3. 构建新数组(V2 对象格式, dialect 由 core 派生) // 紧凑格式: 每个功能一行对象(与需求示例一致), 避免逐字段展开占用大量缩进 QString newArray = "["; for (int i = 0; i < funcList.size(); ++i) { const QString &name = funcList[i]; const QString core = m_coreMap.value(name, Constants::DirEntry::CoreUcbp); const bool viaUmgr = m_viaUmgrMap.value(name, false); const QString dialect = Constants::DirEntry::dialectForCore(core); if (i > 0) { newArray += ","; } newArray += "\n { \"" + Constants::DirEntry::Name + "\": \"" + CustJsonIo::escapeJsonString(name + Constants::DesignExts::Service) + "\", \"" + Constants::DirEntry::Core + "\": \"" + core + "\", \"" + Constants::DirEntry::ViaUmgr + "\": "; newArray += viaUmgr ? "true" : "false"; newArray += ", \"" + Constants::DirEntry::Dialect + "\": \"" + dialect + "\" }"; } if (!funcList.isEmpty()) { newArray += "\n "; } newArray += "]"; // 换行风格跟随原文件: 原文件为 CRLF 时, 新数组文本的 \n 同步为 \r\n。 // 写出必须用二进制模式(不开 QIODevice::Text)——Text 模式会把 \n 再转成 \r\n, // 而保留区域(fromUtf8 原样保留)已含 \r\n, 每保存一次就多累积一个 \r, // 最终文件里出现成串回车符(表现为编辑器中大量空白)。 if (original.contains("\r\n")) { newArray.replace("\n", "\r\n"); } // 4. 拼装并做整体 JSON 合法性校验 QString newContent = content.left(bracketStart) + newArray + content.mid(bracketEnd + 1); QJsonDocument newDoc = QJsonDocument::fromJson(newContent.toUtf8(), &parseError); if (parseError.error != QJsonParseError::NoError) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,新文件 JSON 校验未通过: %1") .arg(parseError.errorString())); return false; } QJsonObject newRoot = newDoc.object(); const QJsonArray verifyDir = newRoot[Constants::CustKeys::ConvertDir].toObject()[Constants::CustKeys::ConvertDirSub].toArray(); if (verifyDir.size() != funcList.size()) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,新文件数组元素数(%1)与清单(%2)不一致") .arg(verifyDir.size()) .arg(funcList.size())); return false; } // 5. 原子写回(二进制模式: 内容字节原样落盘, 不做任何换行转换) // 保存窗口: 写入前开启, 窗口期内 QSaveFile 替换目标文件引发的 // fileChanged/directoryChanged 监控事件直接忽略, 防止自身保存触发重载 // (重载会 refreshTable 强制 scrollToTop, 表现为下拉选择后滚动条回顶); // 失败路径(打开/提交失败)由组件 2s 兜底自动关窗 m_configMonitor->beginSaveWindow(); QSaveFile saveFile(configFilePath()); if (!saveFile.open(QIODevice::WriteOnly)) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,无法写入: %1").arg(configFilePath())); return false; } saveFile.write(newContent.toUtf8()); if (!saveFile.commit()) { LogManager::instance()->logError( QString("全量转码 - 保存配置失败,写入提交失败: %1").arg(configFilePath())); return false; } // 程序自身写入后更新监控 mtime 基准, 避免触发无谓的重载 m_configMonitor->endSave(); return true; } void BatchConvertPage::loadConfig() { QString jsonPath = Constants::custJson(); QFile file(jsonPath); if (!file.open(QIODevice::ReadOnly)) { LogManager::instance()->logWarning( QString("全量转码 - 加载配置失败,无法打开: %1").arg(jsonPath)); return; } QByteArray data = file.readAll(); file.close(); QJsonParseError parseError; QJsonDocument doc = QJsonDocument::fromJson(data, &parseError); if (parseError.error != QJsonParseError::NoError) { LogManager::instance()->logWarning( QString("全量转码 - 加载配置 JSON 解析失败: %1").arg(parseError.errorString())); return; } QJsonObject root = doc.object(); const QJsonArray dir = root[Constants::CustKeys::ConvertDir].toObject()[Constants::CustKeys::ConvertDirSub].toArray(); QStringList items; m_coreMap.clear(); m_viaUmgrMap.clear(); for (const QJsonValue &val : dir) { // V1 字符串元素 / V2 对象元素双格式兼容: 缺失字段按契约默认值兜底 QString item; QString core = Constants::DirEntry::CoreUcbp; bool viaUmgr = false; if (val.isObject()) { const QJsonObject entry = val.toObject(); item = entry.value(Constants::DirEntry::Name).toString(); const QString c = entry.value(Constants::DirEntry::Core).toString(); if (kCoreList.contains(c)) { core = c; } viaUmgr = entry.value(Constants::DirEntry::ViaUmgr).toBool(false); } else { item = val.toString(); } if (item.endsWith(Constants::DesignExts::Service)) { item.chop(Constants::DesignExts::Service.size()); } if (item.isEmpty()) { continue; } items.append(item); m_coreMap.insert(item, core); m_viaUmgrMap.insert(item, viaUmgr); } m_funcList = items; refreshTable(); } QString BatchConvertPage::configFilePath() const { return Constants::custJson(); } QString BatchConvertPage::workbenchTitle() const { return QStringLiteral("全量转码"); } QString BatchConvertPage::configDisplayName() const { return QStringLiteral("cust.json"); } QString BatchConvertPage::addDialogTitle() const { return QStringLiteral("添加功能"); } QString BatchConvertPage::addDialogPlaceholder() const { return QStringLiteral("输入 LS_ 开头的功能名,如 LS_证券参数_股票名单增加"); }