307 lines
13 KiB
C++
307 lines
13 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);
|
||
return QSize(0, special ? 34 : 52);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 卡片
|
||
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) {
|
||
QPainterPath path;
|
||
path.addRoundedRect(r, 6, 6);
|
||
p->fillPath(path, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover"));
|
||
}
|
||
if (selected) { // 左 2px 强调竖条(规范§6.2)
|
||
p->fillRect(QRect(r.left(), r.top() + 4, 2, r.height() - 8),
|
||
geopro::app::tokenColor("accent/primary"));
|
||
}
|
||
|
||
// 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。
|
||
int textLeftPad = 14;
|
||
const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable);
|
||
if (checkable) {
|
||
const int box = 16;
|
||
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; // 复选框右侧留白后再放文本
|
||
}
|
||
|
||
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, 6, -12, -6);
|
||
// 标题
|
||
QFont tf = opt.font;
|
||
tf.setPixelSize(geopro::app::scaledPx(13));
|
||
p->setFont(tf);
|
||
p->setPen(geopro::app::tokenColor("text/primary"));
|
||
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()));
|
||
// 元信息
|
||
if (!meta.isEmpty()) {
|
||
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
|