318 lines
15 KiB
C++
318 lines
15 KiB
C++
#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
|