336 lines
12 KiB
C++
336 lines
12 KiB
C++
#include "mappingtablepage.h"
|
||
#include "utils/Constants.h"
|
||
#include "src/utils/logmanager.h"
|
||
#include "src/utils/custjsonio.h"
|
||
|
||
#include <QVBoxLayout>
|
||
#include <QHBoxLayout>
|
||
#include <QHeaderView>
|
||
#include <QMessageBox>
|
||
#include <QFile>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QJsonParseError>
|
||
#include <QStandardItem>
|
||
#include <QLabel>
|
||
#include <algorithm>
|
||
#include "ElaPushButton.h"
|
||
#include "ElaLineEdit.h"
|
||
#include "ElaTableView.h"
|
||
#include "ElaTheme.h"
|
||
|
||
// 数据映射三页(表替换/函数替换/不转码功能函数)的公共基类:
|
||
// 收编 UI 骨架/删除/编辑置脏/保存保序写/外部修改重载/主题联动等同构样板;
|
||
// 数据结构与校验差异由子类通过 populateModel/collectMappingData/onAddRow 实现
|
||
MappingTablePage::MappingTablePage(int columnCount,
|
||
const QStringList &headers,
|
||
const QString &searchPlaceholder,
|
||
const QString &tipText,
|
||
QWidget *parent)
|
||
: QWidget(parent)
|
||
, m_isLoading(false)
|
||
, m_dirty(false)
|
||
{
|
||
initUI(columnCount, headers, searchPlaceholder, tipText);
|
||
}
|
||
|
||
MappingTablePage::~MappingTablePage()
|
||
{
|
||
}
|
||
|
||
void MappingTablePage::initUI(int columnCount, const QStringList &headers,
|
||
const QString &searchPlaceholder, const QString &tipText)
|
||
{
|
||
QVBoxLayout *mainLayout = new QVBoxLayout(this);
|
||
mainLayout->setContentsMargins(20, 20, 20, 20);
|
||
mainLayout->setSpacing(10);
|
||
|
||
// 搜索框
|
||
QHBoxLayout *searchLayout = new QHBoxLayout;
|
||
m_searchEdit = new ElaLineEdit(this);
|
||
m_searchEdit->setPlaceholderText(searchPlaceholder);
|
||
m_searchEdit->setClearButtonEnabled(true);
|
||
connect(m_searchEdit, &ElaLineEdit::textChanged,
|
||
this, &MappingTablePage::onSearchTextChanged);
|
||
searchLayout->addWidget(m_searchEdit);
|
||
mainLayout->addLayout(searchLayout);
|
||
|
||
m_model = new QStandardItemModel(this);
|
||
m_model->setColumnCount(columnCount);
|
||
m_model->setHorizontalHeaderLabels(headers);
|
||
connect(m_model, &QStandardItemModel::itemChanged,
|
||
this, &MappingTablePage::onModelItemChanged);
|
||
|
||
m_proxyModel = new MappingFilterProxyModel(columnCount, this);
|
||
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||
m_proxyModel->setSourceModel(m_model);
|
||
|
||
m_tableView = new ElaTableView;
|
||
m_tableView->setModel(m_proxyModel);
|
||
m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||
m_tableView->setSelectionMode(QAbstractItemView::ExtendedSelection);
|
||
m_tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
|
||
m_tableView->verticalHeader()->setVisible(false);
|
||
for (int c = 0; c < columnCount; ++c)
|
||
m_tableView->horizontalHeader()->setSectionResizeMode(c, QHeaderView::Stretch);
|
||
mainLayout->addWidget(m_tableView);
|
||
|
||
QHBoxLayout *btnLayout = new QHBoxLayout;
|
||
ElaPushButton *addBtn = new ElaPushButton("添加", this);
|
||
ElaPushButton *delBtn = new ElaPushButton("删除选中", this);
|
||
ElaPushButton *saveBtn = new ElaPushButton("保存", this);
|
||
|
||
// 虚函数经 this 指针调用, 运行时正确分派到子类实现
|
||
connect(addBtn, &ElaPushButton::clicked, this, &MappingTablePage::onAddRow);
|
||
connect(delBtn, &ElaPushButton::clicked, this, &MappingTablePage::onDeleteRow);
|
||
connect(saveBtn, &ElaPushButton::clicked, this, &MappingTablePage::onSave);
|
||
|
||
btnLayout->addWidget(addBtn);
|
||
btnLayout->addWidget(delBtn);
|
||
btnLayout->addStretch();
|
||
btnLayout->addWidget(saveBtn);
|
||
mainLayout->addLayout(btnLayout);
|
||
|
||
QHBoxLayout *tipLayout = new QHBoxLayout;
|
||
m_tipLabel = new QLabel(tipText);
|
||
tipLayout->addWidget(m_tipLabel);
|
||
tipLayout->addStretch();
|
||
mainLayout->addLayout(tipLayout);
|
||
|
||
// 外部修改监控: 双监控+防抖+mtime 回环过滤+保存窗口统一由 JsonFileMonitor 处理
|
||
m_monitor = new JsonFileMonitor(this);
|
||
connect(m_monitor, &JsonFileMonitor::reloadRequested,
|
||
this, &MappingTablePage::onReloadRequested);
|
||
|
||
// tip 标签为主题感知: 跟随 Ela 主题切换, 并按当前主题初始化
|
||
connect(ElaTheme::getInstance(), &ElaTheme::themeModeChanged,
|
||
this, &MappingTablePage::updateTipStyle);
|
||
updateTipStyle();
|
||
}
|
||
|
||
// 由子类构造体内调用(规避构造期虚函数绑定到基类版本): 先读后清空,
|
||
// 读取/解析失败时保留现有模型内容并不清脏, 避免用户随手保存把空表写回磁盘
|
||
void MappingTablePage::load()
|
||
{
|
||
m_isLoading = true;
|
||
|
||
bool readOk = false;
|
||
QJsonObject custJson = readCustJson(&readOk).object();
|
||
if (readOk) {
|
||
// 批量重建期间抑制逐行重绘: 映射表可达数百~上千行, 三个子页共用此路径,
|
||
// 基类统一加抑制子页同时受益(populateModel 为虚函数)
|
||
m_tableView->setUpdatesEnabled(false);
|
||
m_model->removeRows(0, m_model->rowCount());
|
||
}
|
||
|
||
if (readOk) {
|
||
populateModel(custJson);
|
||
m_tableView->setUpdatesEnabled(true);
|
||
}
|
||
|
||
m_isLoading = false;
|
||
// 仅读取/解析成功时清脏: 失败时模型保留了旧内容(可能含未保存编辑),
|
||
// 清脏会让外部变更保护失效, 且"保存"会把旧表写回损坏的文件
|
||
if (readOk) {
|
||
m_dirty = false;
|
||
}
|
||
|
||
// 设置监控并记录 mtime 基准, 供防抖回调过滤自身写入回环
|
||
m_monitor->watch(getCustJsonPath());
|
||
}
|
||
|
||
QString MappingTablePage::getCustJsonPath() const
|
||
{
|
||
return Constants::custJson();
|
||
}
|
||
|
||
QJsonDocument MappingTablePage::readCustJson(bool *ok)
|
||
{
|
||
if (ok) *ok = false;
|
||
const QString jsonPath = getCustJsonPath();
|
||
QFile file(jsonPath);
|
||
if (!file.open(QIODevice::ReadOnly)) {
|
||
LogManager::instance()->logError("无法读取 cust.json: " + jsonPath);
|
||
return QJsonDocument();
|
||
}
|
||
const QByteArray data = file.readAll();
|
||
file.close();
|
||
QJsonParseError error;
|
||
QJsonDocument doc = QJsonDocument::fromJson(data, &error);
|
||
if (error.error != QJsonParseError::NoError) {
|
||
LogManager::instance()->logError(
|
||
QString("cust.json 解析失败: %1 (行 %2)").arg(error.errorString()).arg(error.offset));
|
||
return QJsonDocument();
|
||
}
|
||
if (ok) *ok = true;
|
||
return doc;
|
||
}
|
||
|
||
void MappingTablePage::onDeleteRow()
|
||
{
|
||
QModelIndexList selectedRows = m_tableView->selectionModel()->selectedRows();
|
||
if (selectedRows.isEmpty()) {
|
||
QMessageBox::warning(this, "提示", "请先选中要删除的行!");
|
||
return;
|
||
}
|
||
|
||
QStringList keyNames;
|
||
QList<int> sourceRows;
|
||
for (const QModelIndex &idx : selectedRows) {
|
||
QModelIndex srcIdx = m_proxyModel->mapToSource(idx);
|
||
QStandardItem *keyItem = m_model->item(srcIdx.row(), 0);
|
||
if (keyItem) keyNames.append(keyItem->text());
|
||
sourceRows.append(srcIdx.row());
|
||
}
|
||
|
||
// 单选但键名 item 缺失(空单元格/排序中列被隐藏)时 keyNames 可能为空,
|
||
// 退化为按数量确认, 避免 QList::first() 对空列表断言崩溃
|
||
const QString confirmMsg = (selectedRows.size() == 1 && !keyNames.isEmpty())
|
||
? deleteConfirmSingle(keyNames.first())
|
||
: deleteConfirmMulti(selectedRows.size());
|
||
|
||
QMessageBox::StandardButton reply = QMessageBox::question(this, "确认删除",
|
||
confirmMsg, QMessageBox::Yes | QMessageBox::No);
|
||
if (reply != QMessageBox::Yes) return;
|
||
|
||
// 从高行号到低行号删除,避免索引偏移问题
|
||
std::sort(sourceRows.begin(), sourceRows.end(), std::greater<int>());
|
||
for (int row : sourceRows) {
|
||
m_model->removeRow(row);
|
||
}
|
||
m_dirty = true; // 删除行同样视为未保存修改,纳入外部文件变更保护
|
||
}
|
||
|
||
void MappingTablePage::onModelItemChanged(QStandardItem *item)
|
||
{
|
||
if (m_isLoading) return;
|
||
m_dirty = true; // 用户编辑了映射表,标记有未保存修改
|
||
Q_UNUSED(item);
|
||
}
|
||
|
||
void MappingTablePage::onSave()
|
||
{
|
||
QString errorMessage;
|
||
const SaveResult result = saveToDisk(&errorMessage);
|
||
if (result == SaveResult::Ok) {
|
||
QMessageBox::information(this, "提示", "保存成功!");
|
||
} else if (result == SaveResult::ValidationFailed) {
|
||
QMessageBox::warning(this, "提示", errorMessage);
|
||
} else {
|
||
QMessageBox::critical(this, "错误", errorMessage);
|
||
}
|
||
}
|
||
|
||
MappingTablePage::SaveResult MappingTablePage::saveToDisk(QString *errorMessage)
|
||
{
|
||
if (errorMessage) errorMessage->clear();
|
||
|
||
// 开启保存窗口: 窗口期内监控事件直接忽略
|
||
m_monitor->beginSaveWindow();
|
||
|
||
// 读取原始文件内容以保留顶层 key 顺序
|
||
const QString jsonPath = getCustJsonPath();
|
||
QFile file(jsonPath);
|
||
if (!file.open(QIODevice::ReadOnly)) {
|
||
m_monitor->endSave(); // 早退必须关闭窗口,否则监控被永久抑制
|
||
if (errorMessage) *errorMessage = "无法读取 cust.json!";
|
||
return SaveResult::Error;
|
||
}
|
||
const QString rawJson = QString::fromUtf8(file.readAll());
|
||
file.close();
|
||
|
||
QJsonParseError error;
|
||
QJsonDocument doc = QJsonDocument::fromJson(rawJson.toUtf8(), &error);
|
||
if (error.error != QJsonParseError::NoError) {
|
||
m_monitor->endSave();
|
||
if (errorMessage) *errorMessage = "cust.json 解析失败: " + error.errorString();
|
||
return SaveResult::Error;
|
||
}
|
||
QJsonObject custJson = doc.object();
|
||
|
||
// 提取原始 key 顺序
|
||
const QStringList keyOrder = CustJsonIo::extractTopLevelKeyOrder(rawJson);
|
||
|
||
// 子类校验并回填数据: 校验失败不落盘
|
||
QString validateError;
|
||
const QHash<QString, QString> overrideRaw = collectMappingData(custJson, validateError);
|
||
if (!validateError.isEmpty()) {
|
||
m_monitor->endSave();
|
||
if (errorMessage) *errorMessage = validateError;
|
||
return SaveResult::ValidationFailed;
|
||
}
|
||
|
||
// endSave 以保存后的新 mtime 为基准(writeCustJsonOrdered 同步写完才返回),
|
||
// 成功/失败都要关闭窗口, 且必须先于模态弹窗:
|
||
// 弹窗阻塞期间若发生外部修改, 迟到的 endSave 会把外部修改的 mtime
|
||
// 当作自身保存基准, 导致外部变更被静默吞掉
|
||
const bool writeOk = CustJsonIo::writeCustJsonOrdered(jsonPath, custJson, keyOrder, overrideRaw);
|
||
m_monitor->endSave();
|
||
if (writeOk) {
|
||
m_dirty = false; // 保存成功后与磁盘内容一致
|
||
LogManager::instance()->logInfo(saveLogText(m_model->rowCount()));
|
||
onSavedToDisk(); // 落盘成功钩子(表替换页覆写: 同步 table_mapping.json)
|
||
return SaveResult::Ok;
|
||
}
|
||
if (errorMessage) *errorMessage = "保存到 cust.json 失败!";
|
||
return SaveResult::Error;
|
||
}
|
||
|
||
void MappingTablePage::onSavedToDisk()
|
||
{
|
||
}
|
||
|
||
void MappingTablePage::onReloadRequested()
|
||
{
|
||
// 有未保存编辑时询问,避免外部修改静默覆盖用户的编辑
|
||
if (m_dirty) {
|
||
QMessageBox::StandardButton reply = QMessageBox::question(this, "文件已变更",
|
||
"cust.json 已被其他页面或外部程序修改,重新加载将丢失当前未保存的编辑。是否重新加载?",
|
||
QMessageBox::Yes | QMessageBox::No);
|
||
if (reply != QMessageBox::Yes) {
|
||
return; // 监控组件已记住本次版本,避免同一份内容反复弹窗询问
|
||
}
|
||
}
|
||
|
||
LogManager::instance()->logInfo(reloadLogText());
|
||
load();
|
||
}
|
||
|
||
void MappingTablePage::onSearchTextChanged(const QString &text)
|
||
{
|
||
// setFilterFixedString 自动转义正则元字符, 按不区分大小写的包含匹配
|
||
m_proxyModel->setFilterFixedString(text);
|
||
}
|
||
|
||
void MappingTablePage::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));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 默认文案虚点实现(子类按需覆写)
|
||
// ---------------------------------------------------------------------------
|
||
QString MappingTablePage::saveLogText(int rowCount) const
|
||
{
|
||
Q_UNUSED(rowCount);
|
||
return "配置已保存到 cust.json";
|
||
}
|
||
|
||
QString MappingTablePage::reloadLogText() const
|
||
{
|
||
return "检测到 cust.json 外部修改,自动刷新数据";
|
||
}
|
||
|
||
QString MappingTablePage::deleteConfirmSingle(const QString &name) const
|
||
{
|
||
return QString("确定要删除 \"%1\" 这条映射吗?").arg(name);
|
||
}
|
||
|
||
QString MappingTablePage::deleteConfirmMulti(int count) const
|
||
{
|
||
return QString("确定要删除选中的 %1 条映射吗?").arg(count);
|
||
}
|