新增按需转码功能;支持批量查询和批量添加
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ private slots:
|
||||
private:
|
||||
void initUI();
|
||||
void loadSettings();
|
||||
void updateThemeStyle(); // 按 Ela 主题(浅色/深色)刷新原生 QLabel/QGroupBox 样式
|
||||
void saveToConfigIni();
|
||||
void loadFromConfigIni();
|
||||
QString getConfigIniPath();
|
||||
|
||||
Reference in New Issue
Block a user