新增按需转码功能;支持批量查询和批量添加

This commit is contained in:
taocong45644
2026-09-15 11:14:25 +08:00
committed by taocong
parent 644fa8b4ce
commit 7b0a7e1687
66 changed files with 4092 additions and 871 deletions
+138 -12
View File
@@ -5,6 +5,7 @@
#include <QHBoxLayout>
#include <QGroupBox>
#include <QFile>
#include <QSaveFile>
#include <QFileInfo>
#include <QDir>
#include <QJsonDocument>
@@ -25,6 +26,7 @@
#include "ElaPushButton.h"
#include "ElaProgressBar.h"
#include "ElaText.h"
#include "ElaTheme.h"
namespace {
@@ -93,6 +95,11 @@ BatchConvertPage::BatchConvertPage(QWidget *parent)
m_lastFileMtime = currentFileMtime();
setupConfigWatcher();
// 灰字进度标签跟随主题明暗切换(上轮修改实际未落盘, 此处补全)
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &BatchConvertPage::updateTipStyle);
updateTipStyle();
}
BatchConvertPage::~BatchConvertPage()
@@ -150,7 +157,6 @@ void BatchConvertPage::initUI()
progressLayout->setSpacing(10);
m_progressLabel = new ElaText("等待开始...");
m_progressLabel->setStyleSheet("color: #666; font-size: 12px;");
m_progressLabel->setFixedHeight(40);
m_progressLabel->setWordWrap(false);
m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
@@ -232,9 +238,13 @@ void BatchConvertPage::updateTable()
editBtn->setIconSize(QSize(20, 20));
editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;");
editBtn->setToolTip("修改");
connect(editBtn, &QPushButton::clicked, this, [this, i]() {
onEditRow(i);
});
// QueuedConnection: onEditRow 经 loadFromCustJson 重建表格会析构 editBtn 自身,
// 同步回调返回到按钮内部事件处理时构成 use-after-free, 故延迟到事件循环执行。
// 捕获函数名而非行号且以按钮为接收者: 排队前表格可能被重建, 行号会指向错误行;
// 函数名是稳定键, 且按钮析构时过期的排队请求随之失效
connect(editBtn, &QPushButton::clicked, editBtn, [this, funcName]() {
onEditRowByName(funcName);
}, Qt::QueuedConnection);
m_funcTable->setIndexWidget(m_tableModel->index(i, 2), editBtn);
}
@@ -263,6 +273,16 @@ void BatchConvertPage::showEvent(QShowEvent *event)
updateTable();
}
// 按函数名定位行再编辑: 表格可能在外部文件监控/切页时被全量重建,
// 排队请求中的旧行号会失效指向错误行, 函数名是唯一稳定键
void BatchConvertPage::onEditRowByName(const QString &funcName)
{
const int row = m_funcList.indexOf(funcName);
if (row >= 0) {
onEditRow(row);
}
}
void BatchConvertPage::onEditRow(int row)
{
if (row < 0 || row >= m_funcList.size()) {
@@ -310,6 +330,8 @@ void BatchConvertPage::onEditRow(int row)
updateTable();
if (saveToCustJson(m_funcList)) {
loadFromCustJson();
} else {
QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust.json 失败(文件被占用或权限不足?),重启后修改将丢失!");
}
}
@@ -376,6 +398,14 @@ void BatchConvertPage::onAddFunction()
QString("成功添加 %1 个函数").arg(addedCount));
}
// 落盘: 新增函数持久化到 cust.json, 否则只写内存重启后丢失(与 onEditRow 保持一致)
if (addedCount > 0) {
if (saveToCustJson(m_funcList)) {
loadFromCustJson();
} else {
QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将丢失!");
}
}
updateTable();
}
@@ -403,6 +433,8 @@ void BatchConvertPage::onRemoveFunction()
updateTable();
if (saveToCustJson(m_funcList)) {
loadFromCustJson();
} else {
QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将恢复原清单!");
}
}
@@ -420,10 +452,21 @@ void BatchConvertPage::onClearTable()
updateTable();
if (saveToCustJson(m_funcList)) {
loadFromCustJson();
} else {
QMessageBox::warning(this, "保存失败", "清空操作已在内存中生效,但写入 cust.json 失败(文件被占用或权限不足?),重启后将恢复原清单!");
}
}
}
void BatchConvertPage::updateTipStyle()
{
if (!m_progressLabel) return;
const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode();
const QString textColor = ElaThemeColor(mode, BasicText).name();
// 仅联动颜色: ElaText 保持默认字号(与"按需转码"页一致), 强设 12px 过小(用户反馈)
m_progressLabel->setStyleSheet(QString("color: %1;").arg(textColor));
}
bool BatchConvertPage::saveToCustJson(const QStringList &funcList)
{
QString jsonPath = Constants::custJson();
@@ -457,22 +500,42 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList)
int bracketStart = dirMatch.capturedEnd() - 1; // 指向 '['
// 括号计数找到匹配的 ']'
// 括号计数找到匹配的 ']'(必须跳过字符串字面量, 否则字符串内的 [ ] 会截断匹配改坏 JSON)
int bracketCount = 1;
int pos = bracketStart + 1;
bool inString = false;
bool escaped = false;
while (pos < content.length() && bracketCount > 0) {
QChar ch = content[pos];
if (ch == '[') bracketCount++;
else if (ch == ']') bracketCount--;
if (inString) {
if (escaped) { escaped = false; }
else if (ch == '\\') { escaped = true; }
else if (ch == '"') { inString = false; }
} else {
if (ch == '"') { inString = true; }
else if (ch == '[') bracketCount++;
else if (ch == ']') bracketCount--;
}
pos++;
}
if (bracketCount != 0) {
LogManager::instance()->logWarning("全量转码 - 保存配置失败,dir 数组括号不匹配");
return false;
}
int bracketEnd = pos; // 指向 ']' 之后一位
// 构建新数组(保持原始缩进风格:项 12 空格,闭合 ] 8 空格)
// 函数名做 JSON 字符串转义, 防止含引号/反斜杠时产生非法 JSON
auto jsonEscape = [](const QString &s) -> QString {
QString out = s;
out.replace('\\', "\\\\");
out.replace('"', "\\\"");
return out;
};
QString newArray = "[";
for (int i = 0; i < funcList.size(); ++i) {
if (i > 0) newArray += ",";
newArray += "\n \"" + funcList[i] + ".service_design\"";
newArray += "\n \"" + jsonEscape(funcList[i]) + ".service_design\"";
}
if (!funcList.isEmpty()) {
newArray += "\n ";
@@ -482,15 +545,30 @@ bool BatchConvertPage::saveToCustJson(const QStringList &funcList)
// 只替换 [bracketStart, bracketEnd) 区间,其余内容原样保留
content.replace(bracketStart, bracketEnd - bracketStart, newArray);
// 写回
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
// 写回前校验替换结果仍是合法 JSON, 防止改坏的配置被原地写回无法恢复
QJsonParseError parseErr;
QJsonDocument::fromJson(content.toUtf8(), &parseErr);
if (parseErr.error != QJsonParseError::NoError) {
LogManager::instance()->logWarning(
QString("全量转码 - 保存配置中止, 替换后 JSON 非法: %1").arg(parseErr.errorString()));
return false;
}
// QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件,
// 避免写入中途崩溃/断电损坏配置
QSaveFile saveFile(jsonPath);
if (!saveFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
LogManager::instance()->logWarning(
QString("全量转码 - 保存配置失败,无法写入: %1").arg(jsonPath));
return false;
}
file.write(content.toUtf8());
file.close();
saveFile.write(content.toUtf8());
if (!saveFile.commit()) {
LogManager::instance()->logWarning(
QString("全量转码 - 保存配置失败,写入提交异常: %1").arg(jsonPath));
return false;
}
// 程序自身写入后更新 mtime,避免触发无谓的重载
m_lastFileMtime = currentFileMtime();
@@ -577,6 +655,14 @@ void BatchConvertPage::onConvertFinished()
m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;");
}
void BatchConvertPage::onConvertFailed(const QString &detail)
{
// 失败收尾: 进度条保持当前进度(不复位 100%, 与成功完成区分), 文字提示转换失败
m_progressLabel->setText(detail.isEmpty() ? "转换失败" : detail);
m_startBtn->setText("开始转换");
m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;");
}
void BatchConvertPage::setTaskBusy(bool busy)
{
if (!m_startBtn) {
@@ -612,6 +698,46 @@ void BatchConvertPage::addFunction(const QString &funcName)
}
}
QPair<int, int> BatchConvertPage::addFunctionsBatch(const QStringList &funcNames, bool notify)
{
// 批量添加(功能查询页多选双击): 不逐条弹窗;
// notify=true 时结束后一次汇总提示, notify=false 静默(由调用方统一汇总), 返回 {新增数, 跳过数}
QStringList added;
int skipped = 0;
for (const QString &funcName : funcNames) {
if (funcName.isEmpty()) {
continue;
}
if (m_funcList.contains(funcName)) {
++skipped;
continue;
}
m_funcList.append(funcName);
added.append(funcName);
}
if (added.isEmpty()) {
if (notify && skipped > 0) {
QMessageBox::information(nullptr, "提示",
QString("所选 %1 个功能均已在\"全量转码\"列表中").arg(skipped));
}
return qMakePair(0, skipped);
}
updateTable();
if (!saveToCustJson(m_funcList)) {
QMessageBox::warning(nullptr, "警告",
QString("添加成功,但保存到 cust.json 失败(%1 个功能)").arg(added.size()));
return qMakePair(-1, -1);
}
if (notify) {
QMessageBox::information(nullptr, "提示",
QString("已将 %1 个功能加入\"全量转码\"").arg(added.size()) +
(skipped > 0 ? QString("(另有 %1 个已存在,已跳过)").arg(skipped) : QString()));
}
return qMakePair(added.size(), skipped);
}
void BatchConvertPage::setupConfigWatcher()
{
QString configPath = getConfigFilePath();
@@ -7,6 +7,7 @@
#include <QFileSystemWatcher>
#include <QTimer>
#include <QDateTime>
#include <QPair>
#include "ElaLineEdit.h"
#include "ElaTableView.h"
#include "ElaProgressBar.h"
@@ -27,8 +28,13 @@ signals:
public slots:
void onProgressUpdate(int progress, const QString& status);
void onConvertFinished();
// 失败收尾: 由 MainWindow 在引擎异常退出或检测到 FAIL 行时调用(见 onPythonRunnerFinished)
void onConvertFailed(const QString &detail);
void onConvertStarted();
void addFunction(const QString &funcName);
// 批量添加(功能查询页多选双击): 不逐条弹窗, 结束后一次汇总提示;
// notify=false 为静默模式(不弹提示框), 返回 {新增数, 跳过数}, 保存失败返回 {-1, -1}(已弹警告)
QPair<int, int> addFunctionsBatch(const QStringList &funcNames, bool notify = true);
// 另一工作台任务运行时由 MainWindow 调用,禁用本页开始按钮
void setTaskBusy(bool busy);
@@ -41,9 +47,11 @@ private slots:
void onRemoveFunction();
void onClearTable();
void onEditRow(int row);
void onEditRowByName(const QString &funcName);
void onConfigFileChanged(const QString &path);
void onConfigDirChanged(const QString &path);
void onReloadTimeout();
void updateTipStyle(); // 灰字进度标签跟随主题明暗切换
private:
void initUI();
+168 -61
View File
@@ -19,9 +19,11 @@
#include <QPainter>
#include <QVBoxLayout>
#include <QProgressDialog>
#include <QSaveFile>
#include <QtConcurrent>
#include <QFutureWatcher>
#include "ElaText.h"
#include "ElaTheme.h"
#include "utils/datacache.h"
#include "utils/logmanager.h"
#include "utils/Constants.h"
@@ -74,6 +76,12 @@ namespace {
// 当前查询的函数/功能行高亮角色:强制绘制蓝色背景,突出查询节点
const int RowHighlightBlueRole = Qt::UserRole + 101;
// runQuery 后台任务的结果: 调用链构建在 worker 线程执行, 完成后回主线程渲染
struct QueryResult {
CallChainAnalyzer::TreeNode downTree;
QList<CallChainAnalyzer::TreeNode> roots;
};
class CallTreeDelegate : public QStyledItemDelegate
{
public:
@@ -128,10 +136,18 @@ CodeSearchPage::CodeSearchPage(QWidget *parent)
}
m_pathByMode[ConvertMode] = convertPath;
m_pathByMode[SearchMode] = searchPath;
// 将(可能为默认的)路径写回配置,确保空配置也会被落盘,下次直接命中
cc.setValue(Constants::CodeSearch::ConvertPathKey, QDir::fromNativeSeparators(convertPath));
cc.setValue(Constants::CodeSearch::SearchPathKey, QDir::fromNativeSeparators(searchPath));
cc.sync();
// 仅在键原本缺失/为空时写回默认路径: 避免每次启动都重写 ini(磁盘写+sync 同步阻塞)
if (convertPath.isEmpty() || searchPath.isEmpty()) {
if (convertPath.isEmpty()) {
cc.setValue(Constants::CodeSearch::ConvertPathKey,
QDir::fromNativeSeparators(m_pathByMode[ConvertMode]));
}
if (searchPath.isEmpty()) {
cc.setValue(Constants::CodeSearch::SearchPathKey,
QDir::fromNativeSeparators(m_pathByMode[SearchMode]));
}
cc.sync();
}
initUI();
}
@@ -140,7 +156,19 @@ CallChainAnalyzer *CodeSearchPage::currentAnalyzer() const
return m_analyzers[m_mode];
}
CodeSearchPage::~CodeSearchPage() = default;
CodeSearchPage::~CodeSearchPage()
{
// 后台任务通过裸指针访问成员 m_analyzers, 页面析构前必须等待在途任务结束,
// 否则 QtConcurrent 线程访问悬垂指针导致退出阶段偶发崩溃。
for (auto *w : m_activeWatchers) {
w->waitForFinished();
}
// 若析构时有在途后台任务, 其 finished 回调不会再执行, 等待光标须在此恢复;
// 守卫检查: 栈空时 restoreOverrideCursor 会 qWarning
if (QApplication::overrideCursor()) {
QApplication::restoreOverrideCursor();
}
}
void CodeSearchPage::initUI()
{
@@ -306,7 +334,10 @@ void CodeSearchPage::initUI()
// 底部静态提示(仿 UF20 功能查询页最下方提示)
QHBoxLayout *tipLayout = new QHBoxLayout();
m_bottomTip = new QLabel("💡 双击左侧业务转码函数查看调用链路");
m_bottomTip->setStyleSheet("color: #666; font-size: 13px;");
// 主题联动: 灰字颜色随明暗模式切换, 原硬编码 #666 在暗色主题下几乎不可读
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &CodeSearchPage::updateTipStyle);
updateTipStyle();
tipLayout->addWidget(m_bottomTip);
tipLayout->addStretch();
mainLayout->addLayout(tipLayout);
@@ -315,6 +346,13 @@ void CodeSearchPage::initUI()
applyMode();
}
void CodeSearchPage::updateTipStyle()
{
const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode();
const QString textColor = ElaThemeColor(mode, BasicText).name();
m_bottomTip->setStyleSheet(QString("color: %1; font-size: 13px;").arg(textColor));
}
void CodeSearchPage::applyMode()
{
const bool convert = (m_mode == ConvertMode);
@@ -402,6 +440,11 @@ void CodeSearchPage::onRefreshIndex()
if (m_indexing) {
return;
}
// 后台任务(在途查询/静默索引)与本次构建共用同一 analyzer, 并发访问构成数据竞争
if (!m_activeWatchers.isEmpty()) {
QMessageBox::information(this, "提示", "当前有任务正在执行, 请稍候再试");
return;
}
m_pathByMode[m_mode] = path;
@@ -411,20 +454,23 @@ void CodeSearchPage::onRefreshIndex()
CallChainAnalyzer *ca = currentAnalyzer();
ca->setRootPath(path);
// 记录发起时的模式与 analyzer: 构建期间模式可能被切换(静默链路不挡 UI),
// 回调必须写回发起模式的条目, 避免跨模式串写
const int mode = m_mode;
// 后台线程构建,期间显示进度框,避免界面卡死
m_indexing = true;
runIndexBuild(ca, "正在刷新代码索引,请稍候...",
[this, path](bool ok, const QString &message) {
[this, path, mode, ca](bool ok, const QString &message) {
m_indexing = false;
if (ok) {
currentAnalyzer()->saveToCache(); // 写入缓存,后续直接读取
m_indexedPathByMode[m_mode] = path;
m_userReSelectedByMode[m_mode] = false;
ca->saveToCache(); // 写入缓存,后续直接读取
m_indexedPathByMode[mode] = path;
m_userReSelectedByMode[mode] = false;
LogManager::instance()->log(QString("[代码检索] %1").arg(message));
QMessageBox::information(this, "刷新成功",
message.isEmpty() ? QString("索引刷新成功") : message);
} else {
m_indexedPathByMode[m_mode] = path; // 避免重复自动重试
m_indexedPathByMode[mode] = path; // 避免重复自动重试
QMessageBox::warning(this, "刷新失败",
message.isEmpty() ? QString("索引刷新失败") : message);
}
@@ -444,7 +490,8 @@ void CodeSearchPage::savePathsToConfig()
void CodeSearchPage::refreshIndexSilent()
{
if (m_indexing) {
if (m_indexing || !m_activeWatchers.isEmpty()) {
// 有构建/查询在途时静默跳过: 与在途任务共用同一 analyzer 会构成数据竞争
return;
}
// 收集已配置路径的模式,后台线程依次异步刷新,避免阻塞转换完成后的界面
@@ -506,10 +553,13 @@ void CodeSearchPage::onFunctionDoubleClicked(const QModelIndex &index)
runQuery(displayName);
}
void CodeSearchPage::populateTree(const QList<CallChainAnalyzer::TreeNode> &rootNodes)
void CodeSearchPage::populateTree(const QList<CallChainAnalyzer::TreeNode> &rootNodes, int mode)
{
QStandardItemModel *model = m_treeModel[m_mode];
ElaTreeView *tree = m_callTree[m_mode];
// mode<0 时用当前模式(兼容未传 mode 的调用), 否则用调用方指定的发起时模式,
// 避免后台回调期间模式被切换导致结果串写另一模式的树
const int m = (mode >= 0) ? mode : m_mode;
QStandardItemModel *model = m_treeModel[m];
ElaTreeView *tree = m_callTree[m];
model->removeRows(0, model->rowCount());
@@ -697,29 +747,46 @@ QString CodeSearchPage::resolveChineseName(const QString &displayName) const
void CodeSearchPage::autoBuildIndexIfNeeded()
{
const QString path = m_pathByMode[m_mode].trimmed();
// 记录发起时的模式: 自动构建静默无弹窗, 不挡 UI, 构建期间用户可能切换模式,
// 回调必须写回发起模式的条目, 避免跨模式串写
const int mode = m_mode;
const QString path = m_pathByMode[mode].trimmed();
// 该路径此前已尝试过自动构建(无论成败)则不再自动重试:
// 成功场景由 isIndexed() 兜底;失败场景若不读此登记,会导致每次激活页面都重试构建+弹框
if (m_indexedPathByMode[mode] == path) return;
// 仅当:路径有效、尚未索引、非用户手动改过路径、且未正在构建时,才默认自动加载
if (path.isEmpty() || m_userReSelectedByMode[m_mode]
if (path.isEmpty() || m_userReSelectedByMode[mode]
|| currentAnalyzer()->isIndexed() || m_indexing) {
return;
}
// 在途任务(查询/静默刷新)与自动构建共用同一 analyzer, 并发访问构成数据竞争, 静默跳过
if (!m_activeWatchers.isEmpty()) {
return;
}
CallChainAnalyzer *ca = currentAnalyzer();
ca->setRootPath(path);
// 统一的“全量构建/重建”流程:后台线程扫描目录,避免界面卡死;
// 构建完成后写入缓存,供下次直接读取。
auto fullBuild = [this, path]() {
auto fullBuild = [this, path, mode, ca]() {
// 缓存加载回退链的中间窗口可能已有任务在途, 发起前再防一次并发;
// 跳过时同样标记已尝试, 避免每次激活页面都重试
if (!m_activeWatchers.isEmpty()) {
m_indexedPathByMode[mode] = path;
LogManager::instance()->logWarning("[代码检索-自动] 有任务在途, 已跳过自动构建");
return;
}
m_indexing = true;
runIndexBuild(currentAnalyzer(), "正在构建代码索引,请稍候...",
[this, path](bool ok, const QString &message) {
runIndexBuild(ca, "正在构建代码索引,请稍候...",
[this, path, mode, ca](bool ok, const QString &message) {
m_indexing = false;
if (ok) {
currentAnalyzer()->saveToCache();
m_indexedPathByMode[m_mode] = path;
ca->saveToCache();
m_indexedPathByMode[mode] = path;
LogManager::instance()->log(QString("[代码检索-自动] %1").arg(message));
} else {
m_indexedPathByMode[m_mode] = path; // 标记已尝试,避免每次显示重复重试
m_indexedPathByMode[mode] = path; // 标记已尝试,避免每次显示重复重试
}
});
};
@@ -729,14 +796,14 @@ void CodeSearchPage::autoBuildIndexIfNeeded()
if (ca->cacheQuickValid(path)) {
m_indexing = true;
runCacheLoad(ca, "正在加载索引缓存,请稍候...", /*skipFingerprint=*/true,
[this, path, fullBuild](bool ok, const QString &message) {
[this, path, mode, fullBuild](bool ok, const QString &message) {
m_indexing = false;
if (ok) {
m_indexedPathByMode[m_mode] = path;
m_indexedPathByMode[mode] = path;
LogManager::instance()->log(QString("[代码检索-缓存] %1").arg(message));
} else {
// 粗判命中但读缓存失败(极少见,如缓存损坏):回退全量构建
m_indexedPathByMode[m_mode] = path; // 标记已尝试
m_indexedPathByMode[mode] = path; // 标记已尝试
fullBuild();
}
});
@@ -765,7 +832,17 @@ void CodeSearchPage::runAsync(CallChainAnalyzer *ca, const QString &title,
using BuildResult = QPair<bool, QString>;
auto *watcher = new QFutureWatcher<BuildResult>(this);
connect(watcher, &QFutureWatcher<BuildResult>::finished, this, [=]() {
// 在后台线程执行实际任务,避免阻塞 UI 线程
auto future = QtConcurrent::run([=]() -> BuildResult {
return task(ca);
});
// 登记任务句柄, 供析构函数等待在途任务完成
m_activeWatchers.append(watcher);
connect(watcher, &QFutureWatcher<BuildResult>::finished, this,
[this, watcher, dlg, onDone]() {
m_activeWatchers.removeOne(watcher);
const BuildResult r = watcher->result();
watcher->deleteLater();
if (dlg) {
@@ -777,10 +854,6 @@ void CodeSearchPage::runAsync(CallChainAnalyzer *ca, const QString &title,
}
});
// 在后台线程执行实际任务,避免阻塞 UI 线程
auto future = QtConcurrent::run([=]() -> BuildResult {
return task(ca);
});
watcher->setFuture(future);
}
@@ -840,6 +913,15 @@ void CodeSearchPage::runQuery(const QString &displayName)
return;
}
// 后台任务(索引构建/在途查询)与本次查询共用同一 analyzer, 并发访问构成数据竞争
if (!m_activeWatchers.isEmpty()) {
QMessageBox::information(this, "提示", "当前有任务正在执行, 请稍候再试");
return;
}
// 记录发起时的模式: 后台查询运行中模式可能被切换(onPivotClicked 不挡 UI),
// 回调必须写回发起模式的条目, 避免跨模式串写结果树与导出基准路径
const int mode = m_mode;
const QString chineseName = resolveChineseName(displayName);
// 记录当前查询的函数/功能名,供调用链树中对应行蓝色高亮。
@@ -854,37 +936,51 @@ void CodeSearchPage::runQuery(const QString &displayName)
: QString("%1-%2").arg(hi->objectId, hi->chineseName);
}
// buildCallTree/buildCallerForest 在大索引上可能耗时数秒, 放到后台线程执行,
// 完成后回主线程刷新树(树控件只能主线程操作)
QApplication::setOverrideCursor(Qt::WaitCursor);
// buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 "notfound"
CallChainAnalyzer::TreeNode downTree = currentAnalyzer()->buildCallTree(chineseName);
if (downTree.state != "ok") {
QFutureWatcher<QueryResult> *watcher = new QFutureWatcher<QueryResult>(this);
auto *analyzer = currentAnalyzer();
auto future = QtConcurrent::run([analyzer, chineseName]() -> QueryResult {
QueryResult r;
// buildCallTree 内部使用多策略查找,如果未找到根节点会标记为 "notfound"
r.downTree = analyzer->buildCallTree(chineseName);
if (r.downTree.state == "ok") {
// 往前查:构造上游调用方森林(每条链以 downTree 收尾),无上游时退化为单根 downTree
r.roots = analyzer->buildCallerForest(chineseName, r.downTree);
}
return r;
});
m_activeWatchers.append(watcher);
connect(watcher, &QFutureWatcher<QueryResult>::finished, this,
[this, watcher, displayName]() {
m_activeWatchers.removeOne(watcher);
QApplication::restoreOverrideCursor();
QMessageBox::information(this, "未找到",
QString("未在索引中找到函数:%1\n请确认代码检索路径正确并已刷新索引。").arg(displayName));
return;
}
const QueryResult r = watcher->result();
if (r.downTree.state != "ok") {
QMessageBox::information(this, "未找到",
QString("未在索引中找到函数:%1\n请确认代码检索路径正确并已刷新索引。").arg(displayName));
return;
}
// 往前查:构造上游调用方森林(每条链以 downTree 收尾),无上游时退化为单根 downTree
QList<CallChainAnalyzer::TreeNode> roots =
currentAnalyzer()->buildCallerForest(chineseName, downTree);
const QList<CallChainAnalyzer::TreeNode> roots = r.roots;
populateTree(roots);
m_callTree[m_mode]->expandToDepth(2);
populateTree(roots);
QApplication::restoreOverrideCursor();
m_callTree[m_mode]->expandToDepth(2);
// 收集调用链路文件(排除"原代码已支持"),并记录导出基准路径与查询名
QSet<QString> collected;
for (const CallChainAnalyzer::TreeNode &r : roots) {
collectChainFiles(r, collected);
}
m_currentChainRoots = roots;
m_currentChainFilePaths = collected.values();
m_searchRootPath = m_pathEdit->text().trimmed();
m_currentQueryName = displayName;
m_importBtn->setEnabled(!m_currentChainFilePaths.isEmpty());
m_exportChainBtn->setEnabled(!m_currentChainFilePaths.isEmpty());
// 收集调用链路文件(排除"原代码已支持"),并记录导出基准路径与查询名
QSet<QString> collected;
for (const CallChainAnalyzer::TreeNode &root : roots) {
collectChainFiles(root, collected);
}
m_currentChainRoots = roots;
m_currentChainFilePaths = collected.values();
m_searchRootPath = m_pathEdit->text().trimmed();
m_currentQueryName = displayName;
m_importBtn->setEnabled(!m_currentChainFilePaths.isEmpty());
m_exportChainBtn->setEnabled(!m_currentChainFilePaths.isEmpty());
watcher->deleteLater();
});
watcher->setFuture(future);
}
void CodeSearchPage::onExpandAll()
@@ -981,24 +1077,35 @@ void CodeSearchPage::onImportChainFiles()
}
}
// 生成调用链路文本文件(遍历整片森林,含往前查的上游链)
// 生成调用链路文本文件(遍历整片森林,含往前查的上游链)。
// QSaveFile 原子写; 原实现打开失败静默且中断会把半截文件替换到目标位置
QStringList chainLines;
for (const CallChainAnalyzer::TreeNode &r : m_currentChainRoots) {
collectChainLines(r, 0, chainLines);
}
const QString txtPath = QDir(queryDir).filePath("_call_chain.txt");
QFile txtFile(txtPath);
if (txtFile.open(QIODevice::WriteOnly | QIODevice::Text)) {
QSaveFile txtFile(txtPath);
bool chainTxtOk = txtFile.open(QIODevice::WriteOnly | QIODevice::Text);
if (chainTxtOk) {
QTextStream ts(&txtFile);
for (const QString &line : chainLines) {
ts << line << "\n";
}
ts.flush();
chainTxtOk = txtFile.commit();
}
QString msg = QString("已将 %1 个调用文件导出到:\n%2").arg(copied).arg(QDir::toNativeSeparators(queryDir));
if (!failed.isEmpty()) {
msg += QString("\n\n%1 个文件导出失败:\n%2").arg(failed.size()).arg(failed.join("\n"));
}
if (!chainTxtOk) {
// 打开或提交失败必须显式告知: 用户依赖 _call_chain.txt 排查调用链路
QMessageBox::critical(this, "链路文本写入失败",
QString("调用链路文本写入失败(磁盘空间不足/目录只读?):\n%1")
.arg(QDir::toNativeSeparators(txtPath)));
LogManager::instance()->logError("调用链路文本写入失败: " + txtPath);
}
QMessageBox::information(this, "导出完成", msg);
}
+7 -1
View File
@@ -2,6 +2,7 @@
#define CODESEARCHPAGE_H
#include <QWidget>
#include <QFutureWatcher>
#include <QLabel>
#include <QMouseEvent>
#include <QPixmap>
@@ -72,11 +73,13 @@ private slots:
void onQueryByInput();
void savePathsToConfig();
void onPivotClicked(int index);
// 底部提示跟随主题明暗切换
void updateTipStyle();
private:
void initUI();
void loadFunctionList();
void populateTree(const QList<CallChainAnalyzer::TreeNode> &rootNodes);
void populateTree(const QList<CallChainAnalyzer::TreeNode> &rootNodes, int mode = -1);
void autoResizeColumns(ElaTreeView *tree);
void addTreeItem(QStandardItem *parentItem, const CallChainAnalyzer::TreeNode &node);
QString resolveChineseName(const QString &displayName) const;
@@ -132,6 +135,9 @@ private:
QString m_currentQueryName; // 当前查询的函数名称(导出目录名)
QString m_highlightName; // 当前查询的函数/功能名(调用链树中对应行蓝色高亮)
bool m_indexing = false; // 后台索引构建进行中,防止重复触发
// 在途后台任务的 watcher: 任务通过裸指针访问 m_analyzers, 页面析构时必须等待完成防止 use-after-free。
// 存 watcher 指针而非 QFuture 值: QFuture 无 operator== 无法从列表移除, 指针比较即可
QList<QFutureWatcherBase*> m_activeWatchers;
};
#endif // CODESEARCHPAGE_H
+164 -25
View File
@@ -6,6 +6,7 @@
#include <QMessageBox>
#include <QCoreApplication>
#include <QFile>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
@@ -21,6 +22,10 @@
#include "ElaPushButton.h"
#include "ElaLineEdit.h"
#include "ElaText.h"
#include "ElaTheme.h"
#include <QDir>
#include <QFileInfo>
#include <QDateTime>
// 自定义代理模型:支持两列同时过滤
class FuncFilterProxyModel : public QSortFilterProxyModel
@@ -109,23 +114,74 @@ void FunctionMappingPage::initUI()
mainLayout->addLayout(btnLayout);
QHBoxLayout *tipLayout = new QHBoxLayout;
QLabel *tipLabel = new QLabel("💡 双击表格修改数据");
tipLabel->setStyleSheet("color: #666; font-size: 13px;");
tipLayout->addWidget(tipLabel);
m_tipLabel = new QLabel("💡 双击表格修改数据");
tipLayout->addWidget(m_tipLabel);
tipLayout->addStretch();
mainLayout->addLayout(tipLayout);
// 文件监控:检测 cust.json 外部修改后自动刷新
m_fileWatcher = new QFileSystemWatcher(this);
connect(m_fileWatcher, &QFileSystemWatcher::fileChanged, this, &FunctionMappingPage::onFileChanged);
connect(m_fileWatcher, &QFileSystemWatcher::directoryChanged, this, &FunctionMappingPage::onDirChanged);
// 外部变更防抖: 编辑器/生成器常多次小步写文件, 合并为一次处理,
// 弹窗/重载不再直接嵌套在 watcher 回调(嵌套事件循环可重入)
m_reloadTimer = new QTimer(this);
m_reloadTimer->setSingleShot(true);
m_reloadTimer->setInterval(300);
connect(m_reloadTimer, &QTimer::timeout, this, &FunctionMappingPage::onReloadTimeout);
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &FunctionMappingPage::updateTipStyle);
updateTipStyle();
}
void FunctionMappingPage::updateTipStyle()
{
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 FunctionMappingPage::refreshWatchPaths()
{
const QString jsonPath = getCustJsonPath();
if (!jsonPath.isEmpty() && QFile::exists(jsonPath)
&& !m_fileWatcher->files().contains(jsonPath)) {
m_fileWatcher->addPath(jsonPath);
}
const QString dir = QFileInfo(jsonPath).absolutePath();
if (!dir.isEmpty() && QDir(dir).exists()
&& !m_fileWatcher->directories().contains(dir)) {
m_fileWatcher->addPath(dir);
}
}
QDateTime FunctionMappingPage::currentFileMtime() const
{
QFileInfo info(getCustJsonPath());
return info.exists() ? info.lastModified() : QDateTime();
}
void FunctionMappingPage::loadData()
{
m_isLoading = true;
bool readOk = false;
QJsonObject custJson = readCustJson(&readOk);
if (!readOk) {
// 读取/解析失败时保留现有模型内容并不清脏: 原实现先清空后读取, 失败即空表,
// 用户随手点"保存"就把空映射写回磁盘, 全部数据永久丢失
m_isLoading = false;
LogManager::instance()->logWarning(
"cust.json 读取/解析失败, 保留当前表格内容; 请先恢复文件再刷新, 直接保存会以空数据覆盖");
refreshWatchPaths();
return;
}
m_model->removeRows(0, m_model->rowCount());
QJsonObject custJson = readCustJson();
if (custJson.contains("functionReplace")) {
QJsonObject dataObj = custJson["functionReplace"].toObject();
QStringList keys = dataObj.keys();
@@ -138,11 +194,11 @@ void FunctionMappingPage::loadData()
}
m_isLoading = false;
m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志
// 设置文件监控
QString jsonPath = getCustJsonPath();
if (!m_fileWatcher->files().contains(jsonPath))
m_fileWatcher->addPath(jsonPath);
// 设置文件+目录双监控并记录 mtime 基准, 供防抖回调过滤自身写入回环
refreshWatchPaths();
m_lastFileMtime = currentFileMtime();
}
QString FunctionMappingPage::getCustJsonPath() const
@@ -150,8 +206,9 @@ QString FunctionMappingPage::getCustJsonPath() const
return Constants::custJson();
}
QJsonObject FunctionMappingPage::readCustJson()
QJsonObject FunctionMappingPage::readCustJson(bool *ok)
{
if (ok) *ok = false;
QString jsonPath = getCustJsonPath();
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
@@ -163,22 +220,47 @@ QJsonObject FunctionMappingPage::readCustJson()
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
LogManager::instance()->logError("cust.json 解析失败: " + error.errorString());
LogManager::instance()->logError(
QString("cust.json 解析失败: %1 (行 %2)").arg(error.errorString()).arg(error.offset));
return QJsonObject();
}
if (ok) *ok = true;
return doc.object();
}
// 从原始 JSON 文本中提取顶层 key 的顺序
// 从原始 JSON 文本中提取顶层 key 的顺序:
// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。
// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失
static QStringList extractTopLevelKeyOrder(const QString &rawJson)
{
QStringList keys;
QRegularExpression re("\"(\\w+)\"\\s*:");
auto it = re.globalMatch(rawJson);
while (it.hasNext()) {
QString key = it.next().captured(1);
if (!keys.contains(key))
keys.append(key);
int depth = 0;
bool inString = false;
bool escaped = false;
int strStart = -1;
for (int i = 0; i < rawJson.length(); ++i) {
const QChar ch = rawJson[i];
if (inString) {
if (escaped) { escaped = false; }
else if (ch == '\\') { escaped = true; }
else if (ch == '"') {
inString = false;
// 字符串闭合后跳过空白, 紧跟冒号且处于最外层对象内 → 顶层键
int j = i + 1;
while (j < rawJson.length() && rawJson[j].isSpace()) ++j;
if (depth == 1 && strStart >= 0 && j < rawJson.length()
&& rawJson[j] == ':') {
const QString key = rawJson.mid(strStart + 1, i - strStart - 1);
if (!key.isEmpty() && !keys.contains(key)) {
keys.append(key);
}
}
}
} else {
if (ch == '"') { inString = true; strStart = i; }
else if (ch == '{' || ch == '[') { ++depth; }
else if (ch == '}' || ch == ']') { --depth; }
}
}
return keys;
}
@@ -249,8 +331,10 @@ bool FunctionMappingPage::writeCustJson(const QJsonObject &json, const QStringLi
const QString &orderedKey, const QList<QPair<QString, QString>> &orderedData)
{
QString jsonPath = getCustJsonPath();
QFile file(jsonPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
// QSaveFile 原子写: cust.json 为全局共享配置(承载 functionReplace 与 convert.dir 等),
// 裸 Truncate 写中途异常会留下截断/损坏的 JSON, 与 tablemappingpage 保持一致
QSaveFile file(jsonPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
LogManager::instance()->logError("无法写入 cust.json: " + jsonPath);
return false;
}
@@ -270,7 +354,11 @@ bool FunctionMappingPage::writeCustJson(const QJsonObject &json, const QStringLi
}
}
out << "\n}\n";
file.close();
out.flush();
if (!file.commit()) {
LogManager::instance()->logError("cust.json 提交失败(磁盘空间/权限/占用?): " + jsonPath);
return false;
}
return true;
}
@@ -368,11 +456,13 @@ void FunctionMappingPage::onDeleteRow()
for (int row : sourceRows) {
m_model->removeRow(row);
}
m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护
}
void FunctionMappingPage::onModelItemChanged(QStandardItem *item)
{
if (m_isLoading) return;
m_dirty = true; // 用户编辑了映射表,标记有未保存修改(与 TableMappingPage 行为一致)
Q_UNUSED(item);
}
@@ -380,11 +470,16 @@ void FunctionMappingPage::onSave()
{
// 标记正在保存,忽略文件监控事件
m_isSaving = true;
// 递增保存代数: 2s 延迟复位标记时比对代数, 窗口期内再次保存时旧计时器失效,
// 不会把新一轮 m_isSaving 提前复位(提前复位后外部事件误触发重载询问)
const quint64 seq = ++m_saveSeq;
// 读取原始文件内容以保留顶层 key 顺序
QString jsonPath = getCustJsonPath();
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
// 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制
m_isSaving = false;
QMessageBox::critical(this, "错误", "无法读取 cust.json!");
return;
}
@@ -394,6 +489,8 @@ void FunctionMappingPage::onSave()
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
// 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制
m_isSaving = false;
QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString());
return;
}
@@ -418,6 +515,8 @@ void FunctionMappingPage::onSave()
custJson["functionReplace"] = functionReplaceObj;
if (writeCustJson(custJson, keyOrder, "functionReplace", funcData)) {
m_dirty = false; // 保存成功后与磁盘内容一致
m_lastFileMtime = currentFileMtime(); // 更新 mtime 基准, 防抖回调据此过滤自身写入
LogManager::instance()->logInfo("函数映射配置已保存到 cust.json");
QMessageBox::information(this, "提示", "保存成功!");
} else {
@@ -425,20 +524,60 @@ void FunctionMappingPage::onSave()
m_isSaving = false;
}
// 延迟重置保存标记,让文件监控忽略本次写入事件
QTimer::singleShot(2000, this, [this]() {
// 延迟重置保存标记,让文件监控忽略本次写入事件(比对代数, 见 onSave 开头)
QTimer::singleShot(2000, this, [this, seq]() {
if (m_saveSeq != seq) return; // 窗口期内又有新保存, 本计时器失效
m_isSaving = false;
// 重新添加监控路径
QString path = getCustJsonPath();
if (!m_fileWatcher->files().contains(path))
m_fileWatcher->addPath(path);
refreshWatchPaths();
});
}
void FunctionMappingPage::onFileChanged(const QString &path)
{
// Windows 下文件被整文件替换后 watcher 会自动移除该路径,先补挂保证后续事件仍能收到
if (m_fileWatcher && !m_fileWatcher->files().contains(path) && QFile::exists(path)) {
m_fileWatcher->addPath(path);
}
if (m_isSaving) return; // 自身写入触发的回环,忽略
m_reloadTimer->start(); // 防抖: 编辑器保存常触发多次事件,300ms 后统一处理
}
void FunctionMappingPage::onDirChanged(const QString &path)
{
Q_UNUSED(path);
// 目录事件兜底文件被替换/删除重建的场景,重新补挂文件监控路径
refreshWatchPaths();
if (m_isSaving) return;
m_reloadTimer->start();
}
void FunctionMappingPage::onReloadTimeout()
{
m_reloadTimer->stop();
// mtime 比对过滤自身写入回环: mtime 未变说明是刚才自己保存的内容,无需重载。
// <= 而非 ==: 覆盖时钟回拨与 FAT/exFAT 2s 粒度截断的场景(外部修改的 mtime
// 只会更新或持平基准, 不会变旧), NTFS 下两者等价
const QDateTime now = currentFileMtime();
if (!now.isValid() || now <= m_lastFileMtime) return;
// 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑
if (m_dirty) {
QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更",
"cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?",
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
// 记住本次版本,避免同一份内容反复弹窗询问
m_lastFileMtime = now;
refreshWatchPaths();
return;
}
}
m_lastFileMtime = now;
LogManager::instance()->logInfo("检测到 cust.json 外部修改,自动刷新数据");
loadData();
}
+16 -1
View File
@@ -7,9 +7,12 @@
#include <QSortFilterProxyModel>
#include <QPair>
#include <QList>
#include <QDateTime>
#include "ElaTableView.h"
class ElaLineEdit;
class QLabel;
class QTimer;
class FunctionMappingPage : public QWidget
{
@@ -25,22 +28,34 @@ private slots:
void onModelItemChanged(QStandardItem *item);
void onSave();
void onFileChanged(const QString &path);
void onDirChanged(const QString &path);
void onReloadTimeout();
void onSearchTextChanged(const QString &text);
// 提示标签跟随主题明暗切换
void updateTipStyle();
private:
void initUI();
void loadData();
QJsonObject readCustJson();
QJsonObject readCustJson(bool *ok = nullptr);
bool writeCustJson(const QJsonObject &json, const QStringList &keyOrder,
const QString &orderedKey, const QList<QPair<QString, QString>> &orderedData);
QString getCustJsonPath() const;
// 文件+目录双监控补挂(Windows 下整文件替换会自动移除文件监控路径, 目录事件兜底)
void refreshWatchPaths();
QDateTime currentFileMtime() const;
ElaTableView *m_tableView;
QStandardItemModel *m_model;
QSortFilterProxyModel *m_proxyModel;
QFileSystemWatcher *m_fileWatcher;
QTimer *m_reloadTimer = nullptr; // 外部变更防抖(300ms 合并多次小步写入)
QDateTime m_lastFileMtime; // loadData 时基准, onReloadTimeout 据此过滤自身写入回环
QLabel *m_tipLabel = nullptr;
quint64 m_saveSeq = 0; // 保存代数: 2s 延迟复位标记时比对, 防窗口期内二次保存被旧计时器提前复位
bool m_isLoading;
bool m_isSaving;
bool m_dirty = false; // 有未保存的编辑(onModelItemChanged 置脏,loadData/onSave 成功清脏)
};
#endif // FUNCTIONMAPPINGPAGE_H
+154 -21
View File
@@ -6,6 +6,7 @@
#include <QMessageBox>
#include <QCoreApplication>
#include <QFile>
#include <QDir>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
@@ -15,12 +16,16 @@
#include <QTextStream>
#include <QJsonValue>
#include <QLabel>
#include <QSaveFile>
#include <QFileInfo>
#include <QDateTime>
#include <algorithm>
#include <QTimer>
#include "src/utils/logmanager.h"
#include "ElaPushButton.h"
#include "ElaLineEdit.h"
#include "ElaText.h"
#include "ElaTheme.h"
// 自定义代理模型:支持两列同时过滤
class TableFilterProxyModel : public QSortFilterProxyModel
@@ -109,15 +114,29 @@ void TableMappingPage::initUI()
mainLayout->addLayout(btnLayout);
QHBoxLayout *tipLayout = new QHBoxLayout;
QLabel *tipLabel = new QLabel("💡 双击表格修改数据");
tipLabel->setStyleSheet("color: #666; font-size: 13px;");
tipLayout->addWidget(tipLabel);
m_tipLabel = new QLabel("💡 双击表格修改数据");
m_tipLabel->setStyleSheet("color: #666; font-size: 13px;");
tipLayout->addWidget(m_tipLabel);
tipLayout->addStretch();
mainLayout->addLayout(tipLayout);
// 文件监控:检测 cust.json 外部修改后自动刷新
// 文件监控:检测 cust.json 外部修改后自动刷新(文件+目录双监控:
// Windows 下文件被外部程序替换/重建后原文件监控句柄会失效, 目录监控负责补挂)
m_fileWatcher = new QFileSystemWatcher(this);
connect(m_fileWatcher, &QFileSystemWatcher::fileChanged, this, &TableMappingPage::onFileChanged);
connect(m_fileWatcher, &QFileSystemWatcher::directoryChanged, this, &TableMappingPage::onDirChanged);
// 外部变更防抖: 编辑器/生成器常多次小步写文件, 合并为一次处理,
// 弹窗/重载不再直接嵌套在 watcher 回调(嵌套事件循环可重入)
m_reloadTimer = new QTimer(this);
m_reloadTimer->setSingleShot(true);
m_reloadTimer->setInterval(300);
connect(m_reloadTimer, &QTimer::timeout, this, &TableMappingPage::onReloadTimeout);
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &TableMappingPage::updateTipStyle);
updateTipStyle();
}
void TableMappingPage::loadData()
@@ -138,11 +157,31 @@ void TableMappingPage::loadData()
}
m_isLoading = false;
m_dirty = false; // 加载完成后与磁盘内容一致,清除脏标志
// 设置文件监控
QString jsonPath = getCustJsonPath();
if (!m_fileWatcher->files().contains(jsonPath))
// 设置文件监控(文件+目录双监控)并记录 mtime 基准, 供防抖回调过滤自身写入回环
refreshWatchPaths();
m_lastFileMtime = currentFileMtime();
}
void TableMappingPage::refreshWatchPaths()
{
const QString jsonPath = getCustJsonPath();
if (!jsonPath.isEmpty() && QFile::exists(jsonPath)
&& !m_fileWatcher->files().contains(jsonPath)) {
m_fileWatcher->addPath(jsonPath);
}
const QString dir = QFileInfo(jsonPath).absolutePath();
if (!dir.isEmpty() && QDir(dir).exists()
&& !m_fileWatcher->directories().contains(dir)) {
m_fileWatcher->addPath(dir);
}
}
QDateTime TableMappingPage::currentFileMtime() const
{
QFileInfo info(getCustJsonPath());
return info.exists() ? info.lastModified() : QDateTime();
}
QString TableMappingPage::getCustJsonPath() const
@@ -169,16 +208,39 @@ QJsonObject TableMappingPage::readCustJson()
return doc.object();
}
// 从原始 JSON 文本中提取顶层 key 的顺序
// 从原始 JSON 文本中提取顶层 key 的顺序:
// 深度感知扫描(跳过字符串字面量), 仅记录最外层对象的直接键。
// 旧正则 "\w+" 无法匹配中文/特殊字符键, 且会误捕嵌套键, 导致保存时顶层字段丢失
static QStringList extractTopLevelKeyOrder(const QString &rawJson)
{
QStringList keys;
QRegularExpression re("\"(\\w+)\"\\s*:");
auto it = re.globalMatch(rawJson);
while (it.hasNext()) {
QString key = it.next().captured(1);
if (!keys.contains(key))
keys.append(key);
int depth = 0;
bool inString = false;
bool escaped = false;
int strStart = -1;
for (int i = 0; i < rawJson.length(); ++i) {
const QChar ch = rawJson[i];
if (inString) {
if (escaped) { escaped = false; }
else if (ch == '\\') { escaped = true; }
else if (ch == '"') {
inString = false;
// 字符串闭合后跳过空白, 紧跟冒号且处于最外层对象内 → 顶层键
int j = i + 1;
while (j < rawJson.length() && rawJson[j].isSpace()) ++j;
if (depth == 1 && strStart >= 0 && j < rawJson.length()
&& rawJson[j] == ':') {
const QString key = rawJson.mid(strStart + 1, i - strStart - 1);
if (!key.isEmpty() && !keys.contains(key)) {
keys.append(key);
}
}
}
} else {
if (ch == '"') { inString = true; strStart = i; }
else if (ch == '{' || ch == '[') { ++depth; }
else if (ch == '}' || ch == ']') { --depth; }
}
}
return keys;
}
@@ -249,7 +311,9 @@ bool TableMappingPage::writeCustJson(const QJsonObject &json, const QStringList
const QString &orderedKey, const QList<QPair<QString, QString>> &orderedData)
{
QString jsonPath = getCustJsonPath();
QFile file(jsonPath);
// QSaveFile 原子写: 先写临时文件成功后再 commit 替换目标,
// 避免写一半崩溃/断电留下截断损坏的 cust.json(QFile+Truncate 会先清空原文件)
QSaveFile file(jsonPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
LogManager::instance()->logError("无法写入 cust.json: " + jsonPath);
return false;
@@ -270,7 +334,12 @@ bool TableMappingPage::writeCustJson(const QJsonObject &json, const QStringList
}
}
out << "\n}\n";
file.close();
out.flush();
if (!file.commit()) {
LogManager::instance()->logError(
QString("cust.json 提交失败(磁盘空间/权限/占用?): %1").arg(jsonPath));
return false;
}
return true;
}
@@ -368,11 +437,13 @@ void TableMappingPage::onDeleteRow()
for (int row : sourceRows) {
m_model->removeRow(row);
}
m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护
}
void TableMappingPage::onModelItemChanged(QStandardItem *item)
{
if (m_isLoading) return;
m_dirty = true; // 用户编辑了映射表,标记有未保存修改
Q_UNUSED(item);
}
@@ -385,6 +456,8 @@ void TableMappingPage::onSave()
QString jsonPath = getCustJsonPath();
QFile file(jsonPath);
if (!file.open(QIODevice::ReadOnly)) {
// 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制
m_isSaving = false;
QMessageBox::critical(this, "错误", "无法读取 cust.json!");
return;
}
@@ -394,6 +467,8 @@ void TableMappingPage::onSave()
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
// 早退必须复位保存标记,否则文件监控被 m_isSaving 永久抑制
m_isSaving = false;
QMessageBox::critical(this, "错误", "cust.json 解析失败: " + error.errorString());
return;
}
@@ -402,7 +477,9 @@ void TableMappingPage::onSave()
// 提取原始 key 顺序
QStringList keyOrder = extractTopLevelKeyOrder(rawJson);
// 构建有序的表替换数据
// 构建有序的表替换数据。
// 重复 key 校验: 就地编辑第一列可能改成已有表名(JSON 对象同键去重会静默丢失其中一行),
// onAddRow 只覆盖"新增"入口, 编辑入口必须在此兜底
QJsonObject tableCompareObj;
QList<QPair<QString, QString>> tableData;
for (int row = 0; row < m_model->rowCount(); ++row) {
@@ -410,6 +487,13 @@ void TableMappingPage::onSave()
QStandardItem *valItem = m_model->item(row, 1);
if (keyItem && !keyItem->text().trimmed().isEmpty()) {
QString k = keyItem->text().trimmed();
if (tableCompareObj.contains(k)) {
m_isSaving = false;
QMessageBox::warning(this, "提示",
QString("第 %1 行的 UF20 表名 \"%2\" 与其他行重复,请修改后再保存!")
.arg(row + 1).arg(k));
return;
}
QString v = valItem ? valItem->text().trimmed() : "";
tableCompareObj[k] = v;
tableData.append({k, v});
@@ -418,6 +502,8 @@ void TableMappingPage::onSave()
custJson["tableCompareUf2_Uf3"] = tableCompareObj;
if (writeCustJson(custJson, keyOrder, "tableCompareUf2_Uf3", tableData)) {
m_dirty = false; // 保存成功后与磁盘内容一致
m_lastFileMtime = currentFileMtime(); // 更新 mtime 基准, 防抖回调据此过滤自身写入
LogManager::instance()->logInfo("表映射配置已保存到 cust.json");
QMessageBox::information(this, "提示", "保存成功!");
} else {
@@ -428,21 +514,68 @@ void TableMappingPage::onSave()
// 延迟重置保存标记,让文件监控忽略本次写入事件
QTimer::singleShot(2000, this, [this]() {
m_isSaving = false;
// 重新添加监控路径
QString path = getCustJsonPath();
if (!m_fileWatcher->files().contains(path))
m_fileWatcher->addPath(path);
// 重新添加监控路径(文件可能被替换, 目录监控在此期间负责兜底)
refreshWatchPaths();
});
}
void TableMappingPage::onFileChanged(const QString &path)
{
// Windows 下文件被整文件替换后 watcher 会自动移除该路径,先补挂保证后续事件仍能收到
if (m_fileWatcher && !m_fileWatcher->files().contains(path) && QFile::exists(path)) {
m_fileWatcher->addPath(path);
}
if (m_isSaving) return; // 自身写入触发的回环,忽略
m_reloadTimer->start(); // 防抖: 编辑器保存常触发多次事件,300ms 后统一处理
}
void TableMappingPage::onDirChanged(const QString &path)
{
Q_UNUSED(path);
// 目录事件兜底文件被替换/删除重建的场景,重新补挂文件监控路径
refreshWatchPaths();
if (m_isSaving) return;
m_reloadTimer->start();
}
void TableMappingPage::onReloadTimeout()
{
m_reloadTimer->stop();
// mtime 比对过滤自身写入回环: mtime 未变说明是刚才自己保存的内容,无需重载。
// <= 而非 ==: 覆盖时钟回拨与 FAT/exFAT 2s 粒度截断的场景(外部修改的 mtime
// 只会更新或持平基准, 不会变旧), NTFS 下两者等价
const QDateTime now = currentFileMtime();
if (!now.isValid() || now <= m_lastFileMtime) return;
// 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑
if (m_dirty) {
QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更",
"cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?",
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
// 记住本次版本,避免同一份内容反复弹窗询问
m_lastFileMtime = now;
refreshWatchPaths();
return;
}
}
m_lastFileMtime = now;
LogManager::instance()->logInfo("检测到 cust.json 外部修改,自动刷新数据");
loadData();
}
void TableMappingPage::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 TableMappingPage::onSearchTextChanged(const QString &text)
{
static_cast<TableFilterProxyModel*>(m_proxyModel)->setFilterText(text);
+12
View File
@@ -7,9 +7,12 @@
#include <QSortFilterProxyModel>
#include <QPair>
#include <QList>
#include <QDateTime>
#include "ElaTableView.h"
class ElaLineEdit;
class QLabel;
class QTimer;
class TableMappingPage : public QWidget
{
@@ -25,7 +28,10 @@ private slots:
void onModelItemChanged(QStandardItem *item);
void onSave();
void onFileChanged(const QString &path);
void onDirChanged(const QString &path);
void onReloadTimeout();
void onSearchTextChanged(const QString &text);
void updateTipStyle();
private:
void initUI();
@@ -34,6 +40,8 @@ private:
bool writeCustJson(const QJsonObject &json, const QStringList &keyOrder,
const QString &orderedKey, const QList<QPair<QString, QString>> &orderedData);
QString getCustJsonPath() const;
void refreshWatchPaths();
QDateTime currentFileMtime() const;
ElaTableView *m_tableView;
QStandardItemModel *m_model;
@@ -41,6 +49,10 @@ private:
QFileSystemWatcher *m_fileWatcher;
bool m_isLoading;
bool m_isSaving;
bool m_dirty = false; // 有未保存的编辑(onModelItemChanged 置脏,loadData/onSave 成功清脏)
QLabel *m_tipLabel = nullptr;
QTimer *m_reloadTimer = nullptr; // 外部变更防抖(300ms), 弹窗/重载不再嵌套在 watcher 回调内
QDateTime m_lastFileMtime; // 上次加载/保存后的文件 mtime, 过滤自身写入回环与目录噪声
};
#endif // TABLEMAPPINGPAGE_H
@@ -1,23 +0,0 @@
#include "functionsearchpage.h"
#include <QVBoxLayout>
#include <QLabel>
FunctionSearchPage::FunctionSearchPage(QWidget *parent)
: QWidget(parent)
{
initUI();
}
FunctionSearchPage::~FunctionSearchPage()
{
}
void FunctionSearchPage::initUI()
{
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setContentsMargins(30, 30, 30, 30);
QLabel *label = new QLabel("功能查询");
label->setStyleSheet("font-size: 18px; font-weight: bold;");
layout->addWidget(label);
}
@@ -1,26 +0,0 @@
#ifndef FUNCTIONSEARCHPAGE_H
#define FUNCTIONSEARCHPAGE_H
#include <QWidget>
#include <QStandardItemModel>
#include "ElaLineEdit.h"
#include "ElaPushButton.h"
#include "ElaTableView.h"
class FunctionSearchPage : public QWidget
{
Q_OBJECT
public:
explicit FunctionSearchPage(QWidget *parent = nullptr);
~FunctionSearchPage();
private:
void initUI();
ElaLineEdit *m_searchEdit;
ElaTableView *m_resultTable;
QStandardItemModel *m_tableModel;
};
#endif
@@ -4,12 +4,33 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QHeaderView>
#include <QMessageBox>
#include <QStandardItem>
#include <QRegularExpression>
#include <QMenu>
#include "ElaComboBox.h"
#include "ElaTheme.h"
namespace {
const char* kDefaultTip =
"💡 双击或右键选中行加入转码工作台:LS功能 → \"全量转码\";其它功能 → \"按需转码\"。"
"批量查询示例:LS_证券参数, LS_股票名单(逗号/空格分隔)";
// 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符
// 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar)
QStringList splitKeywords(const QString& text)
{
QString normalized = text;
normalized.replace(QStringLiteral(","), QStringLiteral(" "))
.replace(QStringLiteral(";"), QStringLiteral(" "))
.replace(',', ' ')
.replace(';', ' ');
return normalized.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
}
}
UF20FunctionSearchPage::UF20FunctionSearchPage(QWidget *parent)
: QWidget(parent)
, m_tipLabel(nullptr)
, m_exactMatch(false)
{
initUI();
@@ -35,7 +56,7 @@ void UF20FunctionSearchPage::initUI()
m_searchEdit = new ElaSearchEdit;
m_searchEdit->setPlaceholderText("输入查询内容");
m_searchEdit->setFixedSize(330, 35);
m_searchEdit->setSearchEditSize(330, 35);
connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &UF20FunctionSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &UF20FunctionSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &UF20FunctionSearchPage::onExactMatchToggled);
@@ -55,16 +76,38 @@ void UF20FunctionSearchPage::initUI()
m_resultTable->setMinimumHeight(400);
m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_resultTable->setSelectionMode(QAbstractItemView::SingleSelection);
// 扩展多选: Ctrl/Shift 选多行后双击或右键可批量加入转码工作台
m_resultTable->setSelectionMode(QAbstractItemView::ExtendedSelection);
// 右键菜单: 一键添加功能
m_resultTable->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_resultTable, &QWidget::customContextMenuRequested,
this, &UF20FunctionSearchPage::onContextMenuRequested);
connect(m_resultTable, &QTableView::doubleClicked, this, &UF20FunctionSearchPage::onTableDoubleClicked);
layout->addWidget(m_resultTable);
QHBoxLayout *tipLayout = new QHBoxLayout;
QLabel *tipLabel = new QLabel("💡 双击选中行加入转码工作台:LS功能 → \"全量转码\";其它功能 → \"按需转码\"");
tipLabel->setStyleSheet("color: #666; font-size: 13px;");
tipLayout->addWidget(tipLabel);
m_tipLabel = new QLabel(kDefaultTip);
// 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行,
// 用户要求提示单行显示
m_tipLabel->setWordWrap(false);
tipLayout->addWidget(m_tipLabel);
tipLayout->addStretch();
layout->addLayout(tipLayout);
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &UF20FunctionSearchPage::updateTipStyle);
updateTipStyle();
}
void UF20FunctionSearchPage::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 UF20FunctionSearchPage::onExactMatchToggled(bool checked)
@@ -74,23 +117,43 @@ void UF20FunctionSearchPage::onExactMatchToggled(bool checked)
void UF20FunctionSearchPage::onSearch()
{
QString keyword = m_searchEdit->text().trimmed();
// 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集)
const QStringList keywords = splitKeywords(m_searchEdit->text());
m_tableModel->removeRows(0, m_tableModel->rowCount());
if (keyword.isEmpty()) {
QMessageBox::information(this, "提示", "请输入查询内容");
if (keywords.isEmpty()) {
// 空关键字: 页内 tip 反馈, 不弹模态框打断操作
if (m_tipLabel) {
m_tipLabel->setText("请输入查询内容");
}
return;
}
QString field = m_searchComboBox->currentData().toString();
QList<FunctionInfo> results = DataCache::instance()->searchUF20Functions(keyword, field, m_exactMatch);
int noMatchCount = 0;
QList<FunctionInfo> results =
DataCache::instance()->searchUF20Functions(keywords, field, m_exactMatch, &noMatchCount);
if (results.isEmpty()) {
QMessageBox::information(this, "提示", "未找到匹配的功能");
// 无结果: 页内 tip 反馈
if (m_tipLabel) {
m_tipLabel->setText("⚠️ 未找到匹配的功能");
}
return;
}
// 查询成功: tip 显示查询统计 + 操作提示
if (m_tipLabel) {
QString tip = QString("已查询 %1 个业务功能函数").arg(keywords.size());
if (noMatchCount > 0) {
tip += QString("(%1 个无匹配)").arg(noMatchCount);
}
// 统计前缀 + 默认提示(操作说明/批量查询示例)
tip += QString(";%1").arg(QString::fromUtf8(kDefaultTip));
m_tipLabel->setText(tip);
}
for (const FunctionInfo& info : results) {
QList<QStandardItem*> rowItems;
@@ -112,14 +175,52 @@ void UF20FunctionSearchPage::onSearch()
void UF20FunctionSearchPage::onTableDoubleClicked(const QModelIndex &index)
{
if (index.isValid()) {
const QString funcName =
m_tableModel->data(m_tableModel->index(index.row(), 0)).toString();
// LS_ 开头 → 全量转码;其余 → 按需转码(由 MainWindow 接到对应工作台)
if (funcName.startsWith("LS_")) {
emit addLsFunctionToConvert(funcName);
} else {
emit addNonLsFunctionToConvert(funcName);
}
if (!index.isValid()) {
return;
}
// 双击已选中多行时按整个选区批量加入(单行时即单条行为);
// 选区意外为空时按双击行兜底
if (m_resultTable->selectionModel()->selectedRows().isEmpty()) {
m_resultTable->selectRow(index.row());
}
addSelectedRowsToConvert();
}
void UF20FunctionSearchPage::onContextMenuRequested(const QPoint &pos)
{
const QModelIndex index = m_resultTable->indexAt(pos);
if (!index.isValid()) {
return; // 空白区域右键不弹菜单
}
// 右键行不在选区时将其设为唯一选中行(与常规右键交互一致, 选区内右键则保持多选)
if (!m_resultTable->selectionModel()->isSelected(index)) {
m_resultTable->selectRow(index.row());
}
QMenu menu(this);
QAction *addAct = menu.addAction("一键添加功能");
if (menu.exec(m_resultTable->viewport()->mapToGlobal(pos)) == addAct) {
addSelectedRowsToConvert();
}
}
void UF20FunctionSearchPage::addSelectedRowsToConvert()
{
QStringList lsNames;
QStringList nonLsNames;
const QModelIndexList selected = m_resultTable->selectionModel()->selectedRows();
for (const QModelIndex &row : selected) {
const QString funcName =
m_tableModel->data(m_tableModel->index(row.row(), 0)).toString();
if (funcName.isEmpty()) {
continue;
}
// LS_ 开头 → 全量转码;其余 → 按需转码(由 MainWindow 接到对应工作台)
if (funcName.startsWith("LS_")) {
lsNames.append(funcName);
} else {
nonLsNames.append(funcName);
}
}
// 一次 emit 两个列表, 由 MainWindow 静默分流加入并合并为一个提示框
emit addFunctionsToConvert(lsNames, nonLsNames);
}
@@ -7,6 +7,8 @@
#include "components/elasearchedit.h"
#include "ElaTableView.h"
class QLabel;
class UF20FunctionSearchPage : public QWidget
{
Q_OBJECT
@@ -16,22 +18,29 @@ public:
~UF20FunctionSearchPage();
signals:
// 双击按功能类别分流:LS_ 开头 → 全量转码工作台;其余 → 按需转码工作台
void addLsFunctionToConvert(const QString& funcName);
void addNonLsFunctionToConvert(const QString& funcName);
// 一键添加(双击/右键共用): 按 LS_ 前缀分流, LS_ 开头 → 全量转码, 其余 → 按需转码;
// 由 MainWindow 静默加入两个工作台后合并为一个提示框
void addFunctionsToConvert(const QStringList& lsNames, const QStringList& nonLsNames);
private slots:
void onSearch();
void onExactMatchToggled(bool checked);
void onTableDoubleClicked(const QModelIndex &index);
// 右键菜单"一键添加功能": 把当前选中行(可多选)加入转码工作台
void onContextMenuRequested(const QPoint &pos);
// tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随)
void updateTipStyle();
private:
void initUI();
// 收集当前选中行, 按 LS_ 前缀分流 emit(双击与右键共用)
void addSelectedRowsToConvert();
ElaComboBox *m_searchComboBox;
ElaSearchEdit *m_searchEdit;
ElaTableView *m_resultTable;
QStandardItemModel *m_tableModel;
QLabel *m_tipLabel;
bool m_exactMatch;
};
@@ -4,12 +4,31 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QHeaderView>
#include <QMessageBox>
#include <QStandardItem>
#include <QRegularExpression>
#include "ElaComboBox.h"
#include "ElaTheme.h"
namespace {
const char* kDefaultTip =
"💡 支持批量查询(逗号/空格分隔),示例:函数A, 函数B";
// 拆分多关键词: 中英文逗号/分号 + 空白统一视为分隔符
// 全角字符用字符串字面量替换, 多字节字符不能写成字符常量(会成为 multichar)
QStringList splitKeywords(const QString& text)
{
QString normalized = text;
normalized.replace(QStringLiteral(","), QStringLiteral(" "))
.replace(QStringLiteral(";"), QStringLiteral(" "))
.replace(',', ' ')
.replace(';', ' ');
return normalized.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
}
}
UFT3FunctionSearchPage::UFT3FunctionSearchPage(QWidget *parent)
: QWidget(parent)
, m_tipLabel(nullptr)
, m_exactMatch(false)
{
initUI();
@@ -34,8 +53,8 @@ void UFT3FunctionSearchPage::initUI()
searchLayout->addWidget(m_searchComboBox);
m_searchEdit = new ElaSearchEdit;
m_searchEdit->setPlaceholderText("输入查询内容");
m_searchEdit->setFixedSize(330, 35);
m_searchEdit->setPlaceholderText("输入查询内容(逗号/空格分隔可查多个)");
m_searchEdit->setSearchEditSize(330, 35);
connect(m_searchEdit, &ElaSearchEdit::returnPressed, this, &UFT3FunctionSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::searchClicked, this, &UFT3FunctionSearchPage::onSearch);
connect(m_searchEdit, &ElaSearchEdit::exactMatchChanged, this, &UFT3FunctionSearchPage::onExactMatchToggled);
@@ -56,6 +75,30 @@ void UFT3FunctionSearchPage::initUI()
m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_resultTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
layout->addWidget(m_resultTable);
QHBoxLayout *tipLayout = new QHBoxLayout;
m_tipLabel = new QLabel(kDefaultTip);
// 不开折行: wordWrap(true) 的 QLabel 在布局中会自选偏窄宽度把提示拆成多行,
// 用户要求提示单行显示
m_tipLabel->setWordWrap(false);
tipLayout->addWidget(m_tipLabel);
tipLayout->addStretch();
layout->addLayout(tipLayout);
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &UFT3FunctionSearchPage::updateTipStyle);
updateTipStyle();
}
void UFT3FunctionSearchPage::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 UFT3FunctionSearchPage::onExactMatchToggled(bool checked)
@@ -65,23 +108,43 @@ void UFT3FunctionSearchPage::onExactMatchToggled(bool checked)
void UFT3FunctionSearchPage::onSearch()
{
QString keyword = m_searchEdit->text().trimmed();
// 多关键词查询: 支持逗号/空格等分隔多个功能名或功能号, 任一命中即展示(OR 并集)
const QStringList keywords = splitKeywords(m_searchEdit->text());
m_tableModel->removeRows(0, m_tableModel->rowCount());
if (keyword.isEmpty()) {
QMessageBox::information(this, "提示", "请输入查询内容");
if (keywords.isEmpty()) {
// 空关键字: 页内 tip 反馈, 不弹模态框打断操作
if (m_tipLabel) {
m_tipLabel->setText("请输入查询内容");
}
return;
}
QString field = m_searchComboBox->currentData().toString();
QList<FunctionInfo> results = DataCache::instance()->searchUFT3Functions(keyword, field, m_exactMatch);
int noMatchCount = 0;
QList<FunctionInfo> results =
DataCache::instance()->searchUFT3Functions(keywords, field, m_exactMatch, &noMatchCount);
if (results.isEmpty()) {
QMessageBox::information(this, "提示", "未找到匹配的功能");
// 无结果: 页内 tip 反馈
if (m_tipLabel) {
m_tipLabel->setText("⚠️ 未找到匹配的功能");
}
return;
}
// 查询成功: tip 显示查询统计 + 操作提示
if (m_tipLabel) {
QString tip = QString("已查询 %1 个业务功能函数").arg(keywords.size());
if (noMatchCount > 0) {
tip += QString("(%1 个无匹配)").arg(noMatchCount);
}
// 统计前缀 + 默认提示(批量查询说明)
tip += QString(";%1").arg(QString::fromUtf8(kDefaultTip));
m_tipLabel->setText(tip);
}
for (const FunctionInfo& info : results) {
QList<QStandardItem*> rowItems;
@@ -96,7 +159,7 @@ void UFT3FunctionSearchPage::onSearch()
QStandardItem *funcNoItem = new QStandardItem(info.functionNo);
funcNoItem->setFlags(funcNoItem->flags() & ~Qt::ItemIsEditable);
rowItems.append(funcNoItem);
m_tableModel->appendRow(rowItems);
}
}
@@ -7,6 +7,8 @@
#include "components/elasearchedit.h"
#include "ElaTableView.h"
class QLabel;
class UFT3FunctionSearchPage : public QWidget
{
Q_OBJECT
@@ -18,6 +20,8 @@ public:
private slots:
void onSearch();
void onExactMatchToggled(bool checked);
// tip 标签主题感知(Ela 原生控件颜色不随主题切换, 需手动跟随)
void updateTipStyle();
private:
void initUI();
@@ -26,6 +30,7 @@ private:
ElaSearchEdit *m_searchEdit;
ElaTableView *m_resultTable;
QStandardItemModel *m_tableModel;
QLabel *m_tipLabel;
bool m_exactMatch;
};
+119 -54
View File
@@ -3,6 +3,7 @@
#include "utils/logmanager.h"
#include "utils/datacache.h"
#include "utils/business_strategy.h"
#include "ElaTheme.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@@ -27,11 +28,19 @@ MetadataUpdatePage::MetadataUpdatePage(QWidget *parent)
this, &MetadataUpdatePage::onWorkerFinished);
connect(this, &MetadataUpdatePage::sigProgress,
this, &MetadataUpdatePage::onUpdateProgress, Qt::QueuedConnection);
// 卡片背景跟随 Ela 主题(浅色/深色)切换
updateCardStyle();
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &MetadataUpdatePage::updateCardStyle);
}
MetadataUpdatePage::~MetadataUpdatePage()
{
// 后台任务 lambda 捕获 this,页面析构前必须等任务结束。
// 通过协作式取消让递归扫描快速返回,避免长时间无界等待
if (_watcher && _watcher->isRunning()) {
_processor->requestCancel();
_watcher->waitForFinished();
}
}
@@ -55,13 +64,7 @@ void MetadataUpdatePage::initUI()
auto* uft3GroupBox = new QWidget(contentWidget);
uft3GroupBox->setObjectName("CardWidget");
uft3GroupBox->setStyleSheet(R"(
#CardWidget {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
)");
m_cardWidgets << uft3GroupBox; // 背景样式由 updateCardStyle() 按主题统一设置
_updateGridLayout = new QGridLayout(uft3GroupBox);
_updateGridLayout->setContentsMargins(12, 10, 12, 10);
_updateGridLayout->setSpacing(6);
@@ -95,13 +98,7 @@ void MetadataUpdatePage::initUI()
auto* uf20GroupBox = new QWidget(contentWidget);
uf20GroupBox->setObjectName("CardWidget");
uf20GroupBox->setStyleSheet(R"(
#CardWidget {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
)");
m_cardWidgets << uf20GroupBox; // 背景样式由 updateCardStyle() 按主题统一设置
_updateUf2GridLayout = new QGridLayout(uf20GroupBox);
_updateUf2GridLayout->setContentsMargins(12, 10, 12, 10);
_updateUf2GridLayout->setSpacing(6);
@@ -134,7 +131,7 @@ void MetadataUpdatePage::initUI()
gridLayout->addItem(new QSpacerItem(5, 5, QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Minimum), 0, 10, 2, 1);
_forceFullCheckBox = new ElaCheckBox("强制全量更新", contentWidget);
_forceFullCheckBox->setToolTip("勾选后本次将重新扫描并解析全部接口文件、重建增量缓存;不勾选时仅处理有变化的文件");
_forceFullCheckBox->setToolTip("勾选后本次强制重新扫描并解析全部接口文件;不勾选时源目录无变化则自动跳过本次扫描");
connect(_forceFullCheckBox, &QCheckBox::toggled, this, [this](bool checked){
if (checked) {
auto ret = QMessageBox::question(this, "提示",
@@ -172,8 +169,14 @@ void MetadataUpdatePage::showEvent(QShowEvent* event)
// 每次显示页面时重新从 ConfigManager 读取路径
// (用户可能已在设置页面修改过路径)
// 更新任务运行中必须跳过: reloadPaths 在主线程写路径成员, 与后台 worker
// 并发读同一 QString 成员构成数据竞争(隐式共享引用计数非线程安全)
if (_processor) {
_processor->reloadPaths();
if (_watcher->isRunning()) {
LogManager::instance()->log("元数据更新任务进行中, 本次显示跳过路径重载");
} else {
_processor->reloadPaths();
}
}
static bool firstShow = true;
@@ -184,6 +187,24 @@ void MetadataUpdatePage::showEvent(QShowEvent* event)
}
}
void MetadataUpdatePage::updateCardStyle()
{
// 夜间模式用深色卡片背景+描边, 白天模式保持白色卡片(原视觉效果);
// 卡片内复选框文字颜色由 Ela 主题绘制(深色主题为浅色文字),
// 卡片背景跟随主题后, 文字与背景对比度自然恢复正常(修复夜间白字白底不可见)。
const bool isDark = ElaTheme::getInstance()->getThemeMode() == ElaThemeType::Dark;
const QString cardColor = isDark ? ElaThemeColor(ElaThemeType::Dark, DialogBase).name()
: QStringLiteral("#FFFFFF");
const QString borderColor = isDark ? ElaThemeColor(ElaThemeType::Dark, BasicBorder).name()
: QStringLiteral("transparent");
const QString qss = QStringLiteral(
"#CardWidget { background-color: %1; border: 1px solid %2; border-radius: 8px; }")
.arg(cardColor, borderColor);
for (QWidget* card : m_cardWidgets) {
card->setStyleSheet(qss);
}
}
void MetadataUpdatePage::showUpdateItems()
{
// 注意: "数据类型" 需排在 "标准字段" 之前, 标准字段解析依赖数据类型(从缓存库读取)。
@@ -295,34 +316,43 @@ void MetadataUpdatePage::onUpdateUFT3()
UpdateResult MetadataUpdatePage::runUpdateUf2(const QStringList& items)
{
// 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 datatype 表)。
// 默认复选框顺序已正确, 此处兜底防止布局顺序调整后悄悄颠倒
QStringList ordered = items;
const int dtIdx = ordered.indexOf("数据类型");
if (dtIdx > 0)
ordered.move(dtIdx, 0);
bool success = true;
bool interfaceUpdated = false;
int itemCount = items.size();
int idx = 0;
for (const QString& item : items) {
for (const QString& item : ordered) {
if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消
emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount),
QString("正在更新UF20 - %1").arg(item));
if (item == "标准字段") {
processUf2StdFields();
if (!processUf2StdFields()) success = false;
} else if (item == "数据类型") {
processUf2DataType();
if (!processUf2DataType()) success = false;
} else if (item == "标准错误号") {
processUf2ErrNumber();
if (!processUf2ErrNumber()) success = false;
} else if (item == "接口数据") {
if (!processUf2Interface(Constants::toolDir())) {
success = false;
}
interfaceUpdated = true;
} else if (item == "常量") {
processUf2Constants();
if (!processUf2Constants()) success = false;
} else if (item == "菜单功能") {
processUf2MenuFunc();
if (!processUf2MenuFunc()) success = false;
} else if (item == "模块") {
processUf2Module();
if (!processUf2Module()) success = false;
} else if (item == "表结构") {
processUf2Table();
if (!processUf2Table()) success = false;
}
++idx;
}
@@ -333,21 +363,30 @@ UpdateResult MetadataUpdatePage::runUpdateUf2(const QStringList& items)
UpdateResult MetadataUpdatePage::runUpdateUft3(const QStringList& items)
{
// 防御性排序: "数据类型" 必须先于 "标准字段"(标准字段从缓存库读 datatype 表)。
// 默认复选框顺序已正确, 此处兜底防止布局顺序调整后悄悄颠倒
QStringList ordered = items;
const int dtIdx = ordered.indexOf("数据类型");
if (dtIdx > 0)
ordered.move(dtIdx, 0);
bool success = true;
bool interfaceUpdated = false;
int itemCount = items.size();
int idx = 0;
for (const QString& item : items) {
for (const QString& item : ordered) {
if (_processor->isCancelRequested()) break; // 页面析构触发的协作式取消
emit sigProgress((itemCount == 1) ? 0 : (idx * 100 / itemCount),
QString("正在更新UFT30 - %1").arg(item));
if (item == "标准字段") {
processUft3StdFields();
if (!processUft3StdFields()) success = false;
} else if (item == "数据类型") {
processUft3DataType();
if (!processUft3DataType()) success = false;
} else if (item == "表结构") {
processUft3Table();
if (!processUft3Table()) success = false;
} else if (item == "接口数据") {
if (!processUft3Interface(Constants::toolDir())) {
success = false;
@@ -360,19 +399,19 @@ UpdateResult MetadataUpdatePage::runUpdateUft3(const QStringList& items)
return UpdateResult{success, interfaceUpdated};
}
void MetadataUpdatePage::processUf2StdFields()
bool MetadataUpdatePage::processUf2StdFields()
{
_processor->processUf2StdFields();
return _processor->processUf2StdFields();
}
void MetadataUpdatePage::processUf2DataType()
bool MetadataUpdatePage::processUf2DataType()
{
_processor->processUf2DataType();
return _processor->processUf2DataType();
}
void MetadataUpdatePage::processUf2ErrNumber()
bool MetadataUpdatePage::processUf2ErrNumber()
{
_processor->processUf2ErrNumber();
return _processor->processUf2ErrNumber();
}
bool MetadataUpdatePage::processUf2Interface(const QString& basePath)
@@ -380,39 +419,39 @@ bool MetadataUpdatePage::processUf2Interface(const QString& basePath)
return _processor->processUf2Interfaces(basePath);
}
void MetadataUpdatePage::processUf2Constants()
bool MetadataUpdatePage::processUf2Constants()
{
_processor->processUf2Constants();
return _processor->processUf2Constants();
}
void MetadataUpdatePage::processUf2MenuFunc()
bool MetadataUpdatePage::processUf2MenuFunc()
{
_processor->processUf2MenuFunc();
return _processor->processUf2MenuFunc();
}
void MetadataUpdatePage::processUf2Module()
bool MetadataUpdatePage::processUf2Module()
{
_processor->processUf2Module();
return _processor->processUf2Module();
}
void MetadataUpdatePage::processUf2Table()
bool MetadataUpdatePage::processUf2Table()
{
_processor->processUf2Table();
return _processor->processUf2Table();
}
void MetadataUpdatePage::processUft3StdFields()
bool MetadataUpdatePage::processUft3StdFields()
{
_processor->processUft3StdFields();
return _processor->processUft3StdFields();
}
void MetadataUpdatePage::processUft3DataType()
bool MetadataUpdatePage::processUft3DataType()
{
_processor->processUft3DataType();
return _processor->processUft3DataType();
}
void MetadataUpdatePage::processUft3Table()
bool MetadataUpdatePage::processUft3Table()
{
_processor->processUft3Table();
return _processor->processUft3Table();
}
bool MetadataUpdatePage::processUft3Interface(const QString& basePath)
@@ -422,23 +461,49 @@ bool MetadataUpdatePage::processUft3Interface(const QString& basePath)
void MetadataUpdatePage::startUpdate(const QStringList& items, const QString& taskName)
{
// 防御:若上一个任务仍在运行则不再启动(按钮已禁用,正常情况下不会触发)。
// 必须先于状态改写, 避免防御触发时已污染进度条/任务名等 UI 状态
if (_watcher->isRunning()) {
return;
}
m_currentTaskName = taskName;
_updateTimer.start();
_progressBar->setValue(0);
_processLabel->setText(QString("正在准备更新%1...").arg(taskName));
// 防御:若上一个任务仍在运行则不再启动(按钮已禁用,正常情况下不会触发)
if (_watcher->isRunning()) {
return;
}
_processor->resetCancel(); // 重置取消标志,保证新任务不被上一次的请求误杀
if (taskName == "UF20") {
_watcher->setFuture(QtConcurrent::run([this, items]() {
return runUpdateUf2(items);
try {
return runUpdateUf2(items);
} catch (const std::exception& e) {
// 异常存入 QFuture 后 _watcher->result() 会重抛导致 std::terminate,
// 必须在工作体内捕获并降级为失败结果。
// needReload 仍置 true: 各 process* 逐数据集独立落库, 异常前已成功
// 落库的部分必须刷新内存功能缓存/静态 cname 缓存, 否则本轮已更新的
// 接口在功能搜索中不可见, 直至下次成功更新才一致
LogManager::instance()->logError(
QString("UF20 元数据更新任务异常: %1").arg(e.what()));
return UpdateResult{false, true};
} catch (...) {
LogManager::instance()->logError("UF20 元数据更新任务发生未知异常");
return UpdateResult{false, true};
}
}));
} else {
_watcher->setFuture(QtConcurrent::run([this, items]() {
return runUpdateUft3(items);
try {
return runUpdateUft3(items);
} catch (const std::exception& e) {
LogManager::instance()->logError(
QString("UFT3 元数据更新任务异常: %1").arg(e.what()));
return UpdateResult{false, true};
} catch (...) {
LogManager::instance()->logError("UFT3 元数据更新任务发生未知异常");
return UpdateResult{false, true};
}
}));
}
}
+12 -10
View File
@@ -51,6 +51,7 @@ signals:
private:
void initUI();
void showUpdateItems();
void updateCardStyle(); // 根据 Ela 主题(浅色/深色)刷新卡片背景样式
// 后台线程执行体(由 QtConcurrent 调用,禁止直接操作 UI)
UpdateResult runUpdateUf2(const QStringList& items);
@@ -58,18 +59,18 @@ private:
// 在主线程启动后台任务
void startUpdate(const QStringList& items, const QString& taskName);
void processUf2StdFields();
void processUf2DataType();
void processUf2ErrNumber();
void processUf2Constants();
void processUf2MenuFunc();
void processUf2Module();
void processUf2Table();
bool processUf2StdFields();
bool processUf2DataType();
bool processUf2ErrNumber();
bool processUf2Constants();
bool processUf2MenuFunc();
bool processUf2Module();
bool processUf2Table();
bool processUf2Interface(const QString& basePath);
void processUft3StdFields();
void processUft3DataType();
void processUft3Table();
bool processUft3StdFields();
bool processUft3DataType();
bool processUft3Table();
bool processUft3Interface(const QString& basePath);
QGridLayout* _updateGridLayout{nullptr};
@@ -86,6 +87,7 @@ private:
QList<ElaCheckBox*> uft3CheckBoxes;
QList<ElaCheckBox*> uf20CheckBoxes;
QList<QWidget*> m_cardWidgets; // 需要跟随主题切换背景的卡片
MetadataProcessor* _processor{nullptr};
QFutureWatcher<UpdateResult>* _watcher{nullptr};
+119 -14
View File
@@ -8,6 +8,7 @@
#include <QLabel>
#include <QSettings>
#include <QFile>
#include <QSaveFile>
#include <QFileInfo>
#include <QDir>
#include <QJsonDocument>
@@ -25,6 +26,7 @@
#include <cstring>
#include "ElaPushButton.h"
#include "ElaComboBox.h"
#include "ElaTheme.h"
namespace {
@@ -109,6 +111,11 @@ NonLsConvertPage::NonLsConvertPage(QWidget *parent)
m_lastFileMtime = currentFileMtime();
setupConfigWatcher();
// 灰字说明/进度标签跟随主题明暗切换
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &NonLsConvertPage::updateTipStyle);
updateTipStyle();
}
NonLsConvertPage::~NonLsConvertPage()
@@ -126,17 +133,16 @@ void NonLsConvertPage::initUI()
layout->setSpacing(14);
// 按需转码说明条:非 LS 功能,支持单文件/递归两种模式,不含核心文件与 module 模块文件
QLabel *modeTipLabel = new QLabel(
m_tipLabel = new QLabel(
"<b>说明:</b>按需转码针对<b>非 LS 功能</b>转码,支持<b>单文件转码</b>与<b>递归转码</b>两种模式,"
"转码范围不包含核心文件、module 模块文件。"
"<ul style='margin-top:4px; margin-bottom:0px; padding-left:18px;'>"
"<li><b>非递归模式</b>:仅对目标文件本身执行转码,不会处理该文件内调用的函数;</li>"
"<li><b>递归模式</b>:转码目标文件的同时,自动对文件中所调用的关联函数一并完成转码。</li>"
"</ul>");
modeTipLabel->setTextFormat(Qt::RichText);
modeTipLabel->setStyleSheet("color: #666; font-size: 12px;");
modeTipLabel->setWordWrap(true);
layout->addWidget(modeTipLabel);
m_tipLabel->setTextFormat(Qt::RichText);
m_tipLabel->setWordWrap(true);
layout->addWidget(m_tipLabel);
QGroupBox *funcBox = new QGroupBox("转码功能");
QVBoxLayout *funcLayout = new QVBoxLayout(funcBox);
@@ -179,7 +185,6 @@ void NonLsConvertPage::initUI()
progressLayout->setSpacing(10);
m_progressLabel = new ElaText("等待开始...");
m_progressLabel->setStyleSheet("color: #666; font-size: 12px;");
m_progressLabel->setFixedHeight(40);
m_progressLabel->setWordWrap(false);
m_progressLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
@@ -289,9 +294,13 @@ void NonLsConvertPage::updateTable()
editBtn->setIconSize(QSize(20, 20));
editBtn->setStyleSheet("padding: 2px; border: none; background-color: transparent;");
editBtn->setToolTip("修改");
connect(editBtn, &QPushButton::clicked, this, [this, i]() {
onEditRow(i);
});
// QueuedConnection: onEditRow 经 loadFromCustNonLsJson 重建表格会析构 editBtn 自身,
// 同步回调返回到按钮内部事件处理时构成 use-after-free, 故延迟到事件循环执行。
// 捕获函数名而非行号且以按钮为接收者: 排队前表格可能被重建, 行号会指向错误行;
// 函数名是稳定键, 且按钮析构时过期的排队请求随之失效
connect(editBtn, &QPushButton::clicked, editBtn, [this, funcName]() {
onEditRowByName(funcName);
}, Qt::QueuedConnection);
m_funcTable->setIndexWidget(m_tableModel->index(i, ColAction), editBtn);
}
@@ -330,6 +339,16 @@ void NonLsConvertPage::showEvent(QShowEvent *event)
updateTable();
}
// 按函数名定位行再编辑: 表格可能在外部文件监控/切页时被全量重建,
// 排队请求中的旧行号会失效指向错误行, 函数名是唯一稳定键
void NonLsConvertPage::onEditRowByName(const QString &funcName)
{
const int row = m_funcList.indexOf(funcName);
if (row >= 0) {
onEditRow(row);
}
}
void NonLsConvertPage::onEditRow(int row)
{
if (row < 0 || row >= m_funcList.size()) {
@@ -377,6 +396,8 @@ void NonLsConvertPage::onEditRow(int row)
updateTable();
if (saveToCustNonLsJson(m_funcList)) {
loadFromCustNonLsJson();
} else {
QMessageBox::warning(this, "保存失败", "函数名已在内存中修改,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后修改将丢失!");
}
adviseAboutLsNames(this, {newName});
}
@@ -444,7 +465,9 @@ void NonLsConvertPage::onAddFunction()
updateTable();
if (addedCount > 0) {
saveToCustNonLsJson(m_funcList);
if (!saveToCustNonLsJson(m_funcList)) {
QMessageBox::warning(this, "保存失败", "新增函数已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将丢失!");
}
}
}
@@ -473,6 +496,8 @@ void NonLsConvertPage::onRemoveFunction()
updateTable();
if (saveToCustNonLsJson(m_funcList)) {
loadFromCustNonLsJson();
} else {
QMessageBox::warning(this, "保存失败", "删除操作已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将恢复原清单!");
}
}
@@ -491,10 +516,25 @@ void NonLsConvertPage::onClearTable()
updateTable();
if (saveToCustNonLsJson(m_funcList)) {
loadFromCustNonLsJson();
} else {
QMessageBox::warning(this, "保存失败", "清空操作已在内存中生效,但写入 cust_nonls.json 失败(文件被占用或权限不足?),重启后将恢复原清单!");
}
}
}
void NonLsConvertPage::updateTipStyle()
{
const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode();
const QString textColor = ElaThemeColor(mode, BasicText).name();
if (m_tipLabel) {
m_tipLabel->setStyleSheet(QString("color: %1; font-size: 12px;").arg(textColor));
}
if (m_progressLabel) {
// 仅联动颜色: ElaText 保持默认字号(与"全量转码"页一致), 强设 12px 过小(用户反馈)
m_progressLabel->setStyleSheet(QString("color: %1;").arg(textColor));
}
}
bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList)
{
const QString jsonPath = Constants::custNonLsJson();
@@ -503,7 +543,14 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList)
QJsonObject root;
{
QFile file(jsonPath);
if (file.exists() && file.open(QIODevice::ReadOnly)) {
if (file.exists()) {
// 文件存在但无法打开(被占用/权限): 覆盖写会吞掉原内容, 中止保存保留原文件
if (!file.open(QIODevice::ReadOnly)) {
LogManager::instance()->logWarning(
QString("按需转码 - 保存前读取既有配置失败(无法打开),已中止保存: %1")
.arg(jsonPath));
return false;
}
QByteArray data = file.readAll();
file.close();
QJsonParseError parseError;
@@ -511,9 +558,11 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList)
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
root = doc.object();
} else {
// 解析失败: 覆盖写会丢失 convert 外的顶层节点且不可恢复, 中止保存保留原文件
LogManager::instance()->logWarning(
QString("按需转码 - 保存前读取既有配置失败(%1),将按当前清单重建: %2")
QString("按需转码 - 既有配置解析失败(%1),已中止保存避免覆盖: %2")
.arg(parseError.errorString()).arg(jsonPath));
return false;
}
}
}
@@ -543,7 +592,9 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList)
convertObj["ext"] = extArray;
root["convert"] = convertObj;
QFile file(jsonPath);
// QSaveFile 原子写: 先写临时文件, commit 成功才替换目标文件,
// 避免写入中途崩溃/断电损坏配置
QSaveFile file(jsonPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
LogManager::instance()->logWarning(
QString("按需转码 - 保存配置失败,无法写入: %1").arg(jsonPath));
@@ -551,7 +602,11 @@ bool NonLsConvertPage::saveToCustNonLsJson(const QStringList &funcList)
}
QJsonDocument doc(root);
file.write(doc.toJson(QJsonDocument::Indented));
file.close();
if (!file.commit()) {
LogManager::instance()->logWarning(
QString("按需转码 - 保存配置失败,写入提交异常: %1").arg(jsonPath));
return false;
}
// 程序自身写入后更新 mtime,避免触发无谓的重载
m_lastFileMtime = currentFileMtime();
@@ -666,6 +721,14 @@ void NonLsConvertPage::onConvertFinished()
m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;");
}
void NonLsConvertPage::onConvertFailed(const QString &detail)
{
// 失败收尾: 进度条保持当前进度(不复位 100%, 与成功完成区分), 文字提示转换失败
m_progressLabel->setText(detail.isEmpty() ? "转换失败" : detail);
m_startBtn->setText("开始转换");
m_startBtn->setStyleSheet("background-color: #1abc9c; color: white; padding: 10px 30px; font-size: 14px; border: none; border-radius: 4px;");
}
void NonLsConvertPage::setTaskBusy(bool busy)
{
if (!m_startBtn) {
@@ -703,6 +766,48 @@ void NonLsConvertPage::addFunction(const QString &funcName)
}
}
QPair<int, int> NonLsConvertPage::addFunctionsBatch(const QStringList &funcNames, bool notify)
{
// 批量添加(功能查询页多选双击): 不逐条弹窗;
// notify=true 时结束后一次汇总提示, notify=false 静默(由调用方统一汇总), 返回 {新增数, 跳过数}
QStringList added;
int skipped = 0;
for (const QString &funcName : funcNames) {
if (funcName.isEmpty()) {
continue;
}
const QString name = normalizeNonLsName(funcName);
if (m_funcList.contains(name)) {
++skipped;
continue;
}
m_funcList.append(name);
m_recursionMap.insert(name, RecursionNo); // 新增功能默认“否”
added.append(name);
}
if (added.isEmpty()) {
if (notify && skipped > 0) {
QMessageBox::information(nullptr, "提示",
QString("所选 %1 个功能均已在\"按需转码\"列表中").arg(skipped));
}
return qMakePair(0, skipped);
}
updateTable();
if (!saveToCustNonLsJson(m_funcList)) {
QMessageBox::warning(nullptr, "警告",
QString("添加成功,但保存到 cust_nonls.json 失败(%1 个功能)").arg(added.size()));
return qMakePair(-1, -1);
}
if (notify) {
QMessageBox::information(nullptr, "提示",
QString("已将 %1 个功能加入\"按需转码\"").arg(added.size()) +
(skipped > 0 ? QString("(另有 %1 个已存在,已跳过)").arg(skipped) : QString()));
}
return qMakePair(added.size(), skipped);
}
QStringList NonLsConvertPage::buildArguments(bool *ok)
{
if (ok) {
+11
View File
@@ -9,10 +9,13 @@
#include <QFileSystemWatcher>
#include <QTimer>
#include <QDateTime>
#include <QPair>
#include "ElaTableView.h"
#include "ElaProgressBar.h"
#include "ElaText.h"
class QLabel;
// 按需转码工作台(业务转码页 Pivot2)
// 交互与全量转码页(BatchConvertPage)保持一致:
// 函数清单(短名)与“是否递归”开关 -> 持久化到 config/cust_nonls.json
@@ -35,7 +38,12 @@ public slots:
void onProgressUpdate(int progress, const QString &status);
void onConvertStarted();
void onConvertFinished();
// 失败收尾: 由 MainWindow 在引擎异常退出或检测到 FAIL 行时调用(见 onPythonRunnerFinished)
void onConvertFailed(const QString &detail);
void addFunction(const QString &funcName);
// 批量添加(功能查询页多选双击): 不逐条弹窗, 结束后一次汇总提示;
// notify=false 为静默模式(不弹提示框), 返回 {新增数, 跳过数}, 保存失败返回 {-1, -1}(已弹警告)
QPair<int, int> addFunctionsBatch(const QStringList &funcNames, bool notify = true);
// 另一工作台任务运行时由 MainWindow 调用,禁用本页开始按钮
void setTaskBusy(bool busy);
@@ -50,9 +58,11 @@ private slots:
void onRemoveFunction();
void onClearTable();
void onEditRow(int row);
void onEditRowByName(const QString &funcName);
void onConfigFileChanged(const QString &path);
void onConfigDirChanged(const QString &path);
void onReloadTimeout();
void updateTipStyle(); // 灰字说明/进度标签跟随主题明暗切换
private:
void initUI();
@@ -69,6 +79,7 @@ private:
ElaTableView *m_funcTable;
QStandardItemModel *m_tableModel;
QLabel *m_tipLabel = nullptr; // 页首灰字说明条(需主题联动)
QStringList m_funcList;
// 每个清单功能对应的“是否递归”标志:0=否、-1=是(与 cust_nonls.json convert.dir 值一致)
QHash<QString, int> m_recursionMap;
+107 -15
View File
@@ -1,4 +1,4 @@
#include "settingspage.h"
#include "settingspage.h"
#include "utils/Constants.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -9,12 +9,16 @@
#include <QSettings>
#include <QCoreApplication>
#include <QFile>
#include <QSaveFile>
#include <QTextStream>
#include <QSet>
#include <QDir>
#include "ElaLineEdit.h"
#include "ElaComboBox.h"
#include "ElaPushButton.h"
#include "ElaTheme.h"
#include "src/utils/configmanager.h"
#include "src/utils/logmanager.h"
SettingsPage::SettingsPage(QWidget *parent)
: QWidget(parent)
@@ -127,6 +131,28 @@ void SettingsPage::initUI()
// 添加弹性空间
layout->addStretch();
// 原生控件(QLabel/QGroupBox)不随 Ela 主题变化, 按当前主题设置样式,
// 并跟随主题切换信号实时刷新(修复夜间模式文字不可读、白色边框刺眼)
updateThemeStyle();
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
this, &SettingsPage::updateThemeStyle);
}
void SettingsPage::updateThemeStyle()
{
// 修复夜间模式下原生 QLabel(黑色文字)几乎不可读、QGroupBox(白色边框)刺眼的问题:
// 原生控件颜色不随 Ela 主题切换, 此处按当前主题统一设置文字与边框颜色。
// 选择器限定在 QGroupBox 内部, 不影响 QMessageBox/QFileDialog 等原生弹窗。
const ElaThemeType::ThemeMode mode = ElaTheme::getInstance()->getThemeMode();
const QString textColor = ElaThemeColor(mode, BasicText).name();
const QString borderColor = ElaThemeColor(mode, BasicBorder).name();
setStyleSheet(QStringLiteral(
"QGroupBox { color: %1; border: 1px solid %2; border-radius: 8px; margin-top: 12px; }"
"QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left;"
" left: 10px; padding: 0 4px; }"
"QGroupBox QLabel { color: %1; background: transparent; }")
.arg(textColor, borderColor));
}
void SettingsPage::loadSettings()
@@ -134,7 +160,8 @@ void SettingsPage::loadSettings()
loadFromConfigIni();
QSettings settings("UFT30", "ChangeCode");
QString theme = settings.value("Theme", "日间模式").toString();
// 默认"夜间模式"与 Ela 默认主题一致, 保证下拉框显示值与界面实际主题相符
QString theme = settings.value("Theme", "夜间模式").toString();
int index = m_themeCombo->findText(theme);
if (index >= 0) {
m_themeCombo->setCurrentIndex(index);
@@ -295,16 +322,22 @@ void SettingsPage::saveToConfigIni()
newLines << ("createPath=" + createPathVal);
}
QFile outFile(iniPath);
if (outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QByteArray outData;
if (isUtf8) {
outData = newLines.join("\n").toUtf8();
} else {
outData = newLines.join("\n").toLocal8Bit();
}
outFile.write(outData);
outFile.close();
// QSaveFile 原子写: 先写临时文件成功后替换, 中断不会把 config.ini 破坏成半截
QSaveFile outFile(iniPath);
if (!outFile.open(QIODevice::WriteOnly | QIODevice::Text)) {
LogManager::instance()->logError("无法打开 config.ini 进行写入: " + iniPath);
return;
}
QByteArray outData;
if (isUtf8) {
outData = newLines.join("\n").toUtf8();
} else {
outData = newLines.join("\n").toLocal8Bit();
}
outFile.write(outData);
if (!outFile.commit()) {
LogManager::instance()->logError(
QString("config.ini 提交失败(磁盘空间/权限?): %1").arg(iniPath));
}
}
@@ -319,7 +352,39 @@ void SettingsPage::onSaveSettings()
QString uf20Val = m_uf20PathEdit->text().trimmed().replace("/", "\\");
QString ufAcct20Val = m_uf20AccountPathEdit->text().trimmed().replace("/", "\\");
QString createPathVal = m_outputPathEdit->text().trimmed().replace("/", "\\");
// 输入校验: 逐项检查非空与目录存在性; uft30pub 为派生路径一并探测
QStringList problems;
const QList<QPair<QString, QString>> pathChecks = {
{"UFT30项目环境路径", uft30Val},
{"UF20项目环境路径", uf20Val},
{"UF20账户项目环境路径", ufAcct20Val},
{"转码生成路径", createPathVal},
};
for (const auto &check : pathChecks) {
if (check.second.isEmpty()) {
problems << QString("%1: 未填写").arg(check.first);
} else if (!QDir(check.second).exists()) {
problems << QString("%1: 目录不存在 (%2)").arg(check.first, check.second);
}
}
if (uft30Val.isEmpty()) {
problems << "UFT30元数据路径(uft30pub): 依赖 UFT30项目环境路径, 当前无法生成";
} else if (!QDir(uft30Val + "\\upub\\dev_codes").exists()) {
problems << QString("UFT30元数据路径: %1 不存在").arg(uft30Val + "\\upub\\dev_codes");
}
if (!problems.isEmpty()) {
QMessageBox::StandardButton reply =
QMessageBox::question(this, "路径校验未通过",
"以下配置存在问题:\n - " + problems.join("\n - ") +
"\n\n仍要保存吗?",
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
}
configManager->setValue("projectPath", "uft30", uft30Val);
configManager->setValue("projectPath", "uf20", uf20Val);
configManager->setValue("projectPath", "ufAcct20", ufAcct20Val);
@@ -327,8 +392,14 @@ void SettingsPage::onSaveSettings()
QString uft30PubVal = uft30Val + "\\upub\\dev_codes";
configManager->setValue("projectPath", "uft30pub", uft30PubVal);
configManager->saveConfig();
// 检查落盘结果: 磁盘满/只读/父目录被删时 QSaveFile 打开或 commit 都会失败,
// 不检查会让 UI 谎报成功, 用户重启后路径全部丢失
if (!configManager->saveConfig()) {
QMessageBox::critical(this, "保存失败",
"写入 config.ini 失败(磁盘空间不足、目录只读或被占用?),\n本次修改未持久化,重启后将丢失!");
return;
}
QMessageBox::information(this, "保存成功", "配置已保存!");
}
@@ -344,11 +415,23 @@ void SettingsPage::onResetSettings()
m_uf20AccountPathEdit->clear();
m_outputPathEdit->clear();
m_themeCombo->setCurrentIndex(0);
// setCurrentIndex 不触发 onThemeChanged, 手动调用切回日间主题
// (UI 显示"日间模式"但 ElaTheme 仍是原值的不一致状态), 并写回 QSettings
onThemeChanged();
saveToConfigIni();
// 同步 ConfigManager 内存缓存: 不同步的话其他页面 getValue 仍得重置前的旧路径
ConfigManager *configManager = ConfigManager::instance();
configManager->setValue("projectPath", "uft30", "");
configManager->setValue("projectPath", "uf20", "");
configManager->setValue("projectPath", "ufAcct20", "");
configManager->setValue("projectPath", "createPath", "");
QSettings settings("UFT30", "ChangeCode");
settings.clear();
// clear 会把主题键一并清掉, 重置后把当前主题(日间)写回, 重启后保持一致
settings.setValue("Theme", m_themeCombo->currentText());
QMessageBox::information(this, "重置成功", "已恢复默认设置!");
}
}
@@ -387,4 +470,13 @@ void SettingsPage::onSelectOutputPath()
void SettingsPage::onThemeChanged()
{
// 下拉选择即生效: 切换 Ela 主题模式(此前该函数为空, 主题从未被应用,
// 界面始终为 Ela 默认的夜间模式, 与下拉框显示的"日间模式"不符);
// 各页面通过 themeModeChanged 信号实时刷新样式
ElaTheme::getInstance()->setThemeMode(
m_themeCombo->currentIndex() == 1 ? ElaThemeType::Dark : ElaThemeType::Light);
// 直接持久化: 原只在"保存设置"时写 QSettings, 只切主题不点保存时
// 重启后主题回退到默认值, 与用户选择不符
QSettings settings("UFT30", "ChangeCode");
settings.setValue("Theme", m_themeCombo->currentText());
}
+1
View File
@@ -26,6 +26,7 @@ private slots:
private:
void initUI();
void loadSettings();
void updateThemeStyle(); // 按 Ela 主题(浅色/深色)刷新原生 QLabel/QGroupBox 样式
void saveToConfigIni();
void loadFromConfigIni();
QString getConfigIniPath();