geopro/src/app/panels/DatasetListPanel.cpp

318 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "panels/DatasetListPanel.hpp"
#include <QAbstractItemView>
#include <QApplication>
#include <QColor>
#include <QHash>
#include <QKeyEvent>
#include <QListWidget>
#include <QListWidgetItem>
#include <QMouseEvent>
#include <QObject>
#include <QPainter>
#include <QPainterPath>
#include <QString>
#include <QStyle>
#include <QStyledItemDelegate>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
#include "Theme.hpp"
namespace geopro::app {
namespace {
QString humanSize(long long b) {
if (b < 1024) return QStringLiteral("%1 B").arg(b);
const double kb = b / 1024.0;
if (kb < 1024.0) return QStringLiteral("%1 KB").arg(kb, 0, 'f', 1);
return QStringLiteral("%1 MB").arg(kb / 1024.0, 0, 'f', 1);
}
// 数据/文件列表卡片委托:标题+元信息双行、悬停/选中圆角高亮 + 选中左 2px 强调竖条规范§6.2)。
// 特殊行(加载更多 / 占位提示)退回为居中纯文本,不画卡片。
class DatasetCardDelegate : public QStyledItemDelegate {
public:
using QStyledItemDelegate::QStyledItemDelegate;
QSize sizeHint(const QStyleOptionViewItem&, const QModelIndex& idx) const override {
const bool special =
idx.data(kDsLoadMoreRole).toBool() || !(idx.flags() & Qt::ItemIsSelectable);
if (special) return QSize(0, 34);
// 无副标题(容器节点 项目/GS/TM用紧凑矮卡避免标题下大片留白。
const bool hasMeta = idx.data(Qt::DisplayRole).toString().contains(QLatin1Char('\n'));
return QSize(0, hasMeta ? 52 : 30);
}
void paint(QPainter* p, const QStyleOptionViewItem& opt, const QModelIndex& idx) const override {
p->save();
p->setRenderHint(QPainter::Antialiasing, true);
const QString disp = idx.data(Qt::DisplayRole).toString();
// 「加载更多」居中强调色文本hover 时加底)。
if (idx.data(kDsLoadMoreRole).toBool()) {
if (opt.state & QStyle::State_MouseOver) {
QPainterPath bgp;
bgp.addRoundedRect(opt.rect.adjusted(4, 2, -4, -2), 6, 6);
p->fillPath(bgp, geopro::app::tokenColor("bg/hover"));
}
p->setPen(geopro::app::tokenColor("accent/primary"));
p->drawText(opt.rect, Qt::AlignCenter, disp);
p->restore();
return;
}
// 占位提示行(不可选):居中淡色文本。
if (!(idx.flags() & Qt::ItemIsSelectable)) {
p->setPen(geopro::app::tokenColor("text/disabled"));
p->drawText(opt.rect, Qt::AlignCenter, disp);
p->restore();
return;
}
// 选中/hover与对象树ObjectRowDelegate完全一致——整行方角填充非圆角卡+ 左 2px
// accent 竖条贴行左缘满高 + 选中标题加粗。保留双行卡片内容(标题 + 创建时间·类型副标题)。
const QRect r = opt.rect.adjusted(4, 2, -4, -2);
const bool selected = opt.state & QStyle::State_Selected;
const bool hover = opt.state & QStyle::State_MouseOver;
if (selected || hover)
p->fillRect(opt.rect, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover"));
if (selected)
p->fillRect(QRect(opt.rect.left(), opt.rect.top(), 2, opt.rect.height()),
geopro::app::tokenColor("accent/primary"));
// 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。容器节点(项目/GS/TM)不画
// 复选框、名称紧跟展开图标(左留白小,与对象树容器一致;勿为对齐子级而预留空复选框列,
// 否则容器名与展开图标间出现大段空白,见用户 #2 反馈)。
const int box = 16;
int textLeftPad = 6;
const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable);
const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container");
if (checkable) {
QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box);
const auto cs = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
QStyleOptionViewItem o(opt);
o.rect = checkRect;
o.state &= ~QStyle::State_HasFocus;
o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off);
const QWidget* w = opt.widget;
QStyle* st = w ? w->style() : QApplication::style();
st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w);
textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本
} else if (isContainer) {
// 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级
// (14px),与「带框父→带框子」一致,消除带框子级相对容器缩进过大(用户 #6)。只 +12(非整列宽)
// 故名称仍紧邻展开图标、无 #2 的大留白。
textLeftPad = 12;
}
QString title = disp, meta;
const int nl = disp.indexOf(QLatin1Char('\n'));
if (nl >= 0) {
title = disp.left(nl);
meta = disp.mid(nl + 1);
}
const QRect textR = r.adjusted(textLeftPad, 4, -12, -4);
QFont tf = opt.font;
tf.setPixelSize(geopro::app::scaledPx(13));
if (selected) tf.setWeight(QFont::DemiBold); // 选中加粗,与对象树一致
p->setFont(tf);
p->setPen(geopro::app::tokenColor("text/primary"));
if (meta.isEmpty()) {
// 无副标题(容器节点):标题垂直居中整卡,不留下半空白。
p->drawText(textR, Qt::AlignLeft | Qt::AlignVCenter,
p->fontMetrics().elidedText(title, Qt::ElideRight, textR.width()));
} else {
const QRect titleR(textR.left(), textR.top(), textR.width(), textR.height() / 2);
p->drawText(titleR, Qt::AlignLeft | Qt::AlignVCenter,
p->fontMetrics().elidedText(title, Qt::ElideRight, titleR.width()));
QFont mf = opt.font;
mf.setPixelSize(geopro::app::scaledPx(11));
p->setFont(mf);
p->setPen(geopro::app::tokenColor("text/tertiary"));
const QRect metaR(textR.left(), textR.center().y() + 1, textR.width(),
textR.height() / 2);
p->drawText(metaR, Qt::AlignLeft | Qt::AlignVCenter,
p->fontMetrics().elidedText(meta, Qt::ElideRight, metaR.width()));
}
p->restore();
}
bool editorEvent(QEvent* ev, QAbstractItemModel* model, const QStyleOptionViewItem& opt,
const QModelIndex& idx) override {
if (!(idx.flags() & Qt::ItemIsUserCheckable))
return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
const QRect r = opt.rect.adjusted(4, 2, -4, -2);
const int box = 16;
// 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。
const QRect hit(r.left(), r.top(), 12 + box + 8, r.height());
auto toggle = [&]() {
const auto cur = static_cast<Qt::CheckState>(idx.data(Qt::CheckStateRole).toInt());
model->setData(idx, cur == Qt::Checked ? Qt::Unchecked : Qt::Checked, Qt::CheckStateRole);
};
if (ev->type() == QEvent::MouseButtonRelease) {
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() == Qt::LeftButton && hit.contains(me->pos())) {
toggle();
return true;
}
} else if (ev->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(ev);
if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) {
toggle();
return true;
}
}
return QStyledItemDelegate::editorEvent(ev, model, opt, idx);
}
};
} // namespace
namespace {
// 建一条数据集树项不挂载列0 文本 = dsName +「创建时间 · 类型名」data 存各角色。
QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d, const QString& tmObjectId) {
QString text = QString::fromStdString(d.dsName);
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
if (!d.typeName.empty())
sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型
if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub);
auto* item = new QTreeWidgetItem();
item->setText(0, text);
item->setData(0, kDsIdRole, QString::fromStdString(d.id));
item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode));
item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode));
item->setData(0, kDsNameRole, QString::fromStdString(d.dsName));
item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName));
item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime));
item->setData(0, kDsTmObjectIdRole, tmObjectId); // 所属 TM 对象 id白化 structParentId
// 单击 tip显示数据集主要属性名称 / 类型 / 创建时间对齐菜单文档「tip显示ds的主要属性」。
QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName));
if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName));
if (!d.createTime.empty())
tip += QStringLiteral("\n创建时间:%1").arg(QString::fromStdString(d.createTime));
item->setToolTip(0, tip);
return item;
}
} // namespace
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append,
const QString& tmObjectId) {
if (!tree) return;
if (!append) tree->clear();
// id→已在树中的项!append 时为空append分页时含已加载行使新行能挂到既有父下。
QHash<QString, QTreeWidgetItem*> byId;
for (QTreeWidgetItemIterator it(tree); *it; ++it) {
const QString id = (*it)->data(0, kDsIdRole).toString();
if (!id.isEmpty()) byId.insert(id, *it);
}
// 第一遍:本批全部建项(不挂载)并登记 id使同批内的父子也能互相找到。
std::vector<QTreeWidgetItem*> batch;
batch.reserve(rows.size());
for (const auto& d : rows) {
auto* item = makeDatasetItem(d, tmObjectId);
byId.insert(QString::fromStdString(d.id), item);
batch.push_back(item);
}
// 第二遍:按 parentId 挂载。父在集合内→作其子;否则(父是源文件节点/不在本批)→作树根。
for (std::size_t i = 0; i < rows.size(); ++i) {
const QString pid = QString::fromStdString(rows[i].parentId);
QTreeWidgetItem* parent = pid.isEmpty() ? nullptr : byId.value(pid, nullptr);
if (parent && parent != batch[i])
parent->addChild(batch[i]);
else
tree->addTopLevelItem(batch[i]);
}
// 默认折叠(对齐原版:仅显源数据根行,派生数据收在展开箭头内)。
}
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
if (!list) return;
if (!append) list->clear();
if (!append && rows.empty()) {
auto* hint = new QListWidgetItem(QStringLiteral("(暂无文件)"), list);
hint->setFlags(Qt::NoItemFlags);
hint->setTextAlignment(Qt::AlignCenter);
return;
}
for (const auto& d : rows) {
const QString fname =
d.fileName.empty() ? QString::fromStdString(d.dsName) : QString::fromStdString(d.fileName);
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
sub += QStringLiteral(" · %1").arg(humanSize(d.fileSize)); // 再跟大小
const QString text = fname + QStringLiteral("\n%1").arg(sub);
auto* item = new QListWidgetItem(text, list);
item->setData(kDsIdRole, QString::fromStdString(d.id));
item->setData(kDsFileUrlRole, QString::fromStdString(d.fileUrl));
}
}
void applyDatasetCardDelegate(QAbstractItemView* view) {
if (!view) return;
view->setItemDelegate(new DatasetCardDelegate(view));
view->setMouseTracking(true); // 让委托收到 hover 状态
if (auto* list = qobject_cast<QListWidget*>(view)) list->setSpacing(0); // 卡间距由委托内边距控制
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, view,
[view]() { view->viewport()->update(); });
}
QStringList collectDatasetTypeNames(QTreeWidget* tree) {
QStringList types;
if (!tree) return types;
QSet<QString> seen;
for (QTreeWidgetItemIterator it(tree); *it; ++it) {
if ((*it)->data(0, kDsLoadMoreRole).toBool()) continue;
const QString t = (*it)->data(0, kDsTypeNameRole).toString();
if (t.isEmpty() || seen.contains(t)) continue;
seen.insert(t);
types << t;
}
return types;
}
namespace {
// 解析创建时间字符串为日期(容忍 "yyyy-MM-dd HH:mm:ss" / "yyyy-MM-dd" / "yyyy/MM/dd")。
QDate parseRowDate(const QString& s) {
if (s.isEmpty()) return {};
const QString d = s.left(10);
for (const char* fmt : {"yyyy-MM-dd", "yyyy/MM/dd"}) {
QDate v = QDate::fromString(d, QString::fromLatin1(fmt));
if (v.isValid()) return v;
}
return {};
}
// 递归判定项是否匹配(类型在集合内 且 创建日期 >= minDate
bool rowMatches(QTreeWidgetItem* item, const QSet<QString>& visibleTypes, const QDate& minDate) {
const QString t = item->data(0, kDsTypeNameRole).toString();
if (!t.isEmpty() && !visibleTypes.contains(t)) return false;
if (minDate.isValid()) {
const QDate d = parseRowDate(item->data(0, kDsCreateTimeRole).toString());
if (d.isValid() && d < minDate) return false;
}
return true;
}
// 返回该项(或其任一后代)是否可见;据此 setHidden。父项只要有可见后代即保留。
bool applyFilterRec(QTreeWidgetItem* item, const QSet<QString>& visibleTypes, const QDate& minDate) {
if (item->data(0, kDsLoadMoreRole).toBool()) return true; // 「加载更多」行恒显
bool anyChildVisible = false;
for (int i = 0; i < item->childCount(); ++i)
anyChildVisible |= applyFilterRec(item->child(i), visibleTypes, minDate);
const bool selfMatch = rowMatches(item, visibleTypes, minDate);
const bool visible = selfMatch || anyChildVisible;
item->setHidden(!visible);
return visible;
}
} // namespace
void applyDatasetFilter(QTreeWidget* tree, const QSet<QString>& visibleTypes, const QDate& minDate) {
if (!tree) return;
for (int i = 0; i < tree->topLevelItemCount(); ++i)
applyFilterRec(tree->topLevelItem(i), visibleTypes, minDate);
}
} // namespace geopro::app