feat/dataset-detail-chart #5

Merged
gaozheng merged 74 commits from feat/dataset-detail-chart into main 2026-06-13 17:30:37 +08:00
9 changed files with 89 additions and 238 deletions
Showing only changes of commit 5f00cdce7a - Show all commits

View File

@ -15,6 +15,8 @@
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
namespace geopro::app {
@ -37,7 +39,7 @@ QColor statusColor(int s) {
}
} // namespace
ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent)
ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent)
: QDialog(parent), repo_(repo) {
setWindowTitle(QStringLiteral("全部项目"));
resize(980, 560);
@ -125,57 +127,85 @@ ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* pa
query();
}
ProjectListDialog::~ProjectListDialog() {
// 退出契约:模态 exec 关窗后本对话框析构 → abort 在飞请求,防回调打到已析构窗口。
if (typesReq_) typesReq_->abort();
if (queryReq_) queryReq_->abort();
}
void ProjectListDialog::fillTypeFilter() {
typeCombo_->addItem(QStringLiteral("全部类型"), QString());
const auto r = repo_.listProjectTypes();
if (!r.ok) return;
for (const auto& t : r.value)
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
// abort-and-replace最新一次过滤填充为准。
if (typesReq_) typesReq_->abort();
auto* r = repo_.listProjectTypesAsync();
typesReq_ = r;
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
if (r != typesReq_) return; // 身份比对:丢弃迟到/被替换信号
typesReq_.clear();
const auto types = qvariant_cast<std::vector<data::ProjectType>>(v);
for (const auto& t : types)
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
});
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString&) {
if (r != typesReq_) return;
typesReq_.clear();
// 失败时仅保留“全部类型”项(与原同步版失败仅 return 一致)。
});
}
void ProjectListDialog::query() {
const std::string name = nameEdit_->text().trimmed().toStdString();
const std::string typeId = typeCombo_->currentData().toString().toStdString();
const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_);
if (!r.ok) {
// abort-and-replace丢弃上一查询仅最新结果落表。
if (queryReq_) queryReq_->abort();
auto* r = repo_.pageProjectsAsync(name, typeId, pageNo_, pageSize_);
queryReq_ = r;
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
if (r != queryReq_) return; // 身份比对
queryReq_.clear();
const auto page = qvariant_cast<data::ProjectListPage>(v);
total_ = page.total;
const auto& rows = page.rows;
table_->setRowCount(static_cast<int>(rows.size()));
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const auto& p = rows[i];
auto set = [&](int col, const QString& text) {
table_->setItem(i, col, new QTableWidgetItem(text));
};
set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1));
auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name));
nameItem->setData(Qt::UserRole, QString::fromStdString(p.id));
nameItem->setForeground(tokenColor("accent/primary"));
table_->setItem(i, 1, nameItem);
set(2, QString::fromStdString(p.code));
// 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。
auto* statusItem = new QTableWidgetItem(statusText(p.status));
statusItem->setForeground(statusColor(p.status));
if (p.status == 2) {
QFont f = statusItem->font();
f.setBold(true);
statusItem->setFont(f);
}
table_->setItem(i, 3, statusItem);
set(4, QString::fromStdString(p.typeName));
set(5, QString::fromStdString(p.ownerCompany));
set(6, QString::fromStdString(p.responsiblePerson));
set(7, QString::fromStdString(p.createTime));
}
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
pageLabel_->setText(
QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
prevBtn_->setEnabled(pageNo_ > 1);
nextBtn_->setEnabled(pageNo_ < pages);
});
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString& msg) {
if (r != queryReq_) return;
queryReq_.clear();
table_->setRowCount(0);
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error)));
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(msg));
prevBtn_->setEnabled(false);
nextBtn_->setEnabled(false);
return;
}
total_ = r.value.total;
const auto& rows = r.value.rows;
table_->setRowCount(static_cast<int>(rows.size()));
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const auto& p = rows[i];
auto set = [&](int col, const QString& text) {
table_->setItem(i, col, new QTableWidgetItem(text));
};
set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1));
auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name));
nameItem->setData(Qt::UserRole, QString::fromStdString(p.id));
nameItem->setForeground(tokenColor("accent/primary"));
table_->setItem(i, 1, nameItem);
set(2, QString::fromStdString(p.code));
// 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。
auto* statusItem = new QTableWidgetItem(statusText(p.status));
statusItem->setForeground(statusColor(p.status));
if (p.status == 2) {
QFont f = statusItem->font();
f.setBold(true);
statusItem->setFont(f);
}
table_->setItem(i, 3, statusItem);
set(4, QString::fromStdString(p.typeName));
set(5, QString::fromStdString(p.ownerCompany));
set(6, QString::fromStdString(p.responsiblePerson));
set(7, QString::fromStdString(p.createTime));
}
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
prevBtn_->setEnabled(pageNo_ > 1);
nextBtn_->setEnabled(pageNo_ < pages);
});
}
} // namespace geopro::app

View File

@ -1,7 +1,8 @@
#pragma once
#include <QDialog>
#include <QPointer>
#include "repo/IProjectRepository.hpp"
#include "repo/IAsyncProjectRepository.hpp"
class QLineEdit;
class QComboBox;
@ -9,13 +10,16 @@ class QTableWidget;
class QLabel;
class QPushButton;
namespace geopro::data { class NavRequest; }
namespace geopro::app {
// 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。
class ProjectListDialog : public QDialog {
Q_OBJECT
public:
explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr);
explicit ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent = nullptr);
~ProjectListDialog() override;
signals:
void projectChosen(const QString& projectId, const QString& projectName);
@ -24,7 +28,9 @@ private:
void query();
void fillTypeFilter();
data::IProjectRepository& repo_;
data::IAsyncProjectRepository& repo_;
QPointer<data::NavRequest> typesReq_;
QPointer<data::NavRequest> queryReq_;
QLineEdit* nameEdit_ = nullptr;
QComboBox* typeCombo_ = nullptr;
QTableWidget* table_ = nullptr;

View File

@ -193,7 +193,7 @@ constexpr const char* kWgs84 = "EPSG:4326";
// 在给定 QMainWindow 上构建 M1 工作台。
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::data::IProjectRepository& projectRepo,
geopro::data::IAsyncProjectRepository& projectRepo,
geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl)
{

View File

@ -3,7 +3,7 @@
Repository 抽象(**异步契约**QFuture/回调 + 取消 + 分页DTO 与领域模型分离。
子目录(设计 §3、§6
- `repo/` — IProjectRepository, IDatasetRepository
- `repo/` — IAsyncProjectRepository, IDatasetRepository
- `local/` — LocalSampleRepositoryM1QtConcurrent 跑解析)+ 各格式解析器
- `api/` — ApiRepositoryM1 骨架,签名对齐 pop-api
- `dto/` — 后端 JSON DTO + → model 映射

View File

@ -15,17 +15,9 @@ namespace geopro::data {
namespace {
constexpr int kCodeSuccess = 200;
bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; }
// 异步失败谓词(与同步 ok() 同口径:业务码非 200 或网络错误)。
// 异步失败谓词:业务码非 200 或网络错误。
bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }
std::string errorOf(const net::ApiResponse& r, const char* fallback) {
if (!r.msg.isEmpty()) return r.msg.toStdString();
if (!r.rawError.isEmpty()) return r.rawError.toStdString();
return fallback;
}
// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。
QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
@ -34,97 +26,7 @@ QString enc(const std::string& s) {
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
const net::ApiResponse r =
api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")};
return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}};
}
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
const QString path =
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId));
const net::ApiResponse r = api_.postJson(path, QJsonObject{});
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")};
// 切换空间返回新 accessToken必须重新注入后续请求才落到新空间。
const QString token = r.data.value(QStringLiteral("accessToken")).toString();
if (!token.isEmpty()) api_.setToken(token);
return {true, true, {}};
}
RepoResult<ProjectListPage> ApiProjectRepository::pageProjects(const std::string& nameFilter,
const std::string& typeId, int pageNo,
int pageSize) {
QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)},
{QStringLiteral("pageNo"), pageNo},
{QStringLiteral("pageSize"), pageSize}};
if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId);
const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body);
if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")};
return {true, dto::parseProjectPage(r.data), {}};
}
RepoResult<std::vector<ProjectType>> ApiProjectRepository::listProjectTypes() {
const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list"));
if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")};
return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}};
}
RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
// 项目结构(项目根 + GS + TM不含 DS。比 projectWorkbench 干净。
const QString path =
QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}};
}
RepoResult<DsPage> ApiProjectRepository::loadRows(const std::string& projectId,
const std::string& parentId, int parentConfType,
int classifyType, int pageNo) {
const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page")
: QStringLiteral("/business/dsObject/data/page");
const QJsonObject body{
{QStringLiteral("projectId"), QString::fromStdString(projectId)},
{QStringLiteral("structParentId"), QString::fromStdString(parentId)},
{QStringLiteral("structParentConfType"), parentConfType},
{QStringLiteral("classifyTypeList"), QJsonArray{classifyType}},
{QStringLiteral("pageNo"), pageNo},
{QStringLiteral("pageSize"), 5}};
const net::ApiResponse r = api_.postJson(path, body);
if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")};
return {true, dto::parseDsPage(r.data), {}};
}
RepoResult<DynamicForm> ApiProjectRepository::loadObjectDetail(const std::string& objectId,
int confType) {
const QString path =
(confType == 1)
? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId))
: QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")};
return {true, dto::parseDynamicForm(r.data), {}};
}
RepoResult<DynamicForm> ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) {
const QString path =
QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")};
return {true, dto::parseDynamicForm(r.data), {}};
}
RepoResult<std::vector<ExceptionRow>> ApiProjectRepository::loadExceptionsByTm(
const std::string& tmObjectId) {
const QString path =
QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")};
return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}};
}
// ── 异步实现薄封装endpoint/body/parse 与同步版逐一对齐;解析器在 try 内见 ApiNavRequest──
// ── 异步实现(薄封装:解析器在 try 内见 ApiNavRequest──
NavRequest* ApiProjectRepository::listWorkspacesAsync() {
auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list"));

View File

@ -1,5 +1,4 @@
#pragma once
#include "repo/IProjectRepository.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::net { class ApiClient; }
@ -8,26 +7,12 @@ namespace geopro::data {
class NavRequest;
// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。
// token 由调用方注入 ApiClient。
class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository {
// 用共享会话 ApiClient 实现导航异步仓储。token 由调用方注入 ApiClient。
class ApiProjectRepository : public IAsyncProjectRepository {
public:
explicit ApiProjectRepository(net::ApiClient& api);
// ── 同步(旧A6 删除) ──
RepoResult<std::vector<Workspace>> listWorkspaces() override;
RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter, const std::string& typeId,
int pageNo, int pageSize) override;
RepoResult<std::vector<ProjectType>> listProjectTypes() override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
int parentConfType, int classifyType, int pageNo) override;
RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) override;
RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) override;
RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) override;
// ── 异步(新) ── 返回 NavRequest*(薄封装,一方法一请求)。
// ── 异步 ── 返回 NavRequest*(薄封装,一方法一请求)。
NavRequest* listWorkspacesAsync() override;
NavRequest* switchWorkspaceAsync(const std::string& tenantId) override;
NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId,

View File

@ -1,41 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
bool ok = false;
T value{};
std::string error;
};
// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
// 项目分页nameFilter 名称模糊可空、typeId 类型过滤(空=不限、pageNo 从 1 起。
virtual RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter,
const std::string& typeId, int pageNo,
int pageSize) = 0;
// 项目类型列表(弹窗类型过滤下拉)。
virtual RepoResult<std::vector<ProjectType>> listProjectTypes() = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按结构父节点分页拉数据/文件行parentConfType 1=GS 2=TMclassifyType 3=数据 1=文件;
// pageNo 从 1 起pageSize 固定 5。
virtual RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
int parentConfType, int classifyType, int pageNo) = 0;
// 对象详情confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。
virtual RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
// 数据集详情dsObject/dynamicForm/{dsObjectId} → 动态表单。
virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
// 单 TM 异常列表(含异常体归属字段)。
virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
};
} // namespace geopro::data

View File

@ -1,9 +1,7 @@
#include "ApiClient.hpp"
#include "ApiCall.hpp"
#include "ApiResponseParse.hpp"
#include <QEventLoop>
#include <QJsonDocument>
#include <QNetworkAccessManager>
#include <QNetworkReply>
@ -34,14 +32,6 @@ struct ApiClient::Impl {
}
return req;
}
// 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。
static ApiResponse await(QNetworkReply* reply) {
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
return buildResponse(reply);
}
};
ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique<Impl>(std::move(baseUrl))) {}
@ -50,23 +40,6 @@ ApiClient::~ApiClient() = default;
void ApiClient::setToken(const QString& token) { impl_->token = token; }
ApiResponse ApiClient::get(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.get(req);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) {
QNetworkRequest req = impl_->buildRequest(path);
const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply* reply = impl_->nam.post(req, payload);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
IApiCall* ApiClient::getAsync(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.get(req);

View File

@ -21,7 +21,7 @@ struct ApiResponse {
QString rawError;
};
// QtNetwork 的步 HTTP 封装。
// QtNetwork 的步 HTTP 封装。
// 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar
// 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID
// 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。
@ -36,10 +36,6 @@ public:
// 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。
void setToken(const QString& token);
// 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。
ApiResponse get(const QString& path);
ApiResponse postJson(const QString& path, const QJsonObject& body);
// 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
IApiCall* getAsync(const QString& path);
IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);