diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 7fce60b..eae9fe8 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -35,6 +35,8 @@ endif() find_package(Drogon CONFIG REQUIRED) find_package(Threads REQUIRED) +find_path(SQLITE3_INCLUDE_DIR sqlite3.h REQUIRED) +find_library(SQLITE3_LIBRARY sqlite3 REQUIRED) # libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。 set(BUILD_TESTS OFF CACHE BOOL "" FORCE) @@ -56,10 +58,12 @@ add_executable(wind_server ${SOURCES}) target_include_directories(wind_server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${REPO_ROOT}/third_party + ${SQLITE3_INCLUDE_DIR} ) target_link_libraries(wind_server PRIVATE Drogon::Drogon + ${SQLITE3_LIBRARY} Threads::Threads xlsxwriter ) diff --git a/backend/src/auth/AuthManager.cpp b/backend/src/auth/AuthManager.cpp new file mode 100644 index 0000000..1e87b11 --- /dev/null +++ b/backend/src/auth/AuthManager.cpp @@ -0,0 +1,446 @@ +#include "AuthManager.h" + +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +namespace fs = std::filesystem; +using json = nlohmann::json; + +constexpr int kPasswordHashRounds = 20000; +constexpr auto kSessionLifetime = std::chrono::hours(12); +const fs::path kDatabasePath = fs::path("data") / "wind_power.db"; + +struct AccountRecord { + std::string username; + std::string password_salt; + std::string password_hash; + bool is_admin = false; + std::string expires_on; + std::string created_at; + bool enabled = true; +}; + +class Statement { +public: + Statement(sqlite3* database, const char* sql) { + if (sqlite3_prepare_v2(database, sql, -1, &statement_, nullptr) != SQLITE_OK) { + statement_ = nullptr; + } + } + + ~Statement() { + if (statement_ != nullptr) { + sqlite3_finalize(statement_); + } + } + + Statement(const Statement&) = delete; + Statement& operator=(const Statement&) = delete; + + sqlite3_stmt* Get() const { + return statement_; + } + + explicit operator bool() const { + return statement_ != nullptr; + } + +private: + sqlite3_stmt* statement_ = nullptr; +}; + +std::string ColumnText(sqlite3_stmt* statement, int column) { + const auto* value = sqlite3_column_text(statement, column); + return value == nullptr ? "" : reinterpret_cast(value); +} + +std::string CurrentDate() { + const auto now = std::time(nullptr); + std::tm local_time = {}; + localtime_r(&now, &local_time); + char buffer[11] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &local_time); + return buffer; +} + +std::string CurrentDateTime() { + const auto now = std::time(nullptr); + std::tm local_time = {}; + localtime_r(&now, &local_time); + char buffer[20] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local_time); + return buffer; +} + +bool IsValidDate(const std::string& value) { + if (!std::regex_match(value, std::regex(R"(^\d{4}-\d{2}-\d{2}$)"))) { + return false; + } + std::tm parsed = {}; + if (strptime(value.c_str(), "%Y-%m-%d", &parsed) == nullptr) { + return false; + } + parsed.tm_isdst = -1; + const auto timestamp = std::mktime(&parsed); + if (timestamp == static_cast(-1)) { + return false; + } + std::tm normalized = {}; + localtime_r(×tamp, &normalized); + char buffer[11] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &normalized); + return value == buffer; +} + +bool IsExpired(const AccountRecord& account) { + return !account.expires_on.empty() && CurrentDate() > account.expires_on; +} + +std::string HashPassword(const std::string& password, const std::string& salt) { + std::string digest = password + ":" + salt; + for (int round = 0; round < kPasswordHashRounds; ++round) { + digest = drogon::utils::getSha256(salt + ":" + digest); + } + return digest; +} + +bool ConstantTimeEquals(const std::string& left, const std::string& right) { + if (left.size() != right.size()) { + return false; + } + unsigned char difference = 0; + for (size_t index = 0; index < left.size(); ++index) { + difference |= static_cast(left[index] ^ right[index]); + } + return difference == 0; +} + +bool ValidateUsername(const std::string& username, std::string& error) { + if (!std::regex_match(username, std::regex(R"(^[A-Za-z0-9_.-]{3,32}$)"))) { + error = "账号只能包含字母、数字、点、下划线或短横线,长度为 3-32 位"; + return false; + } + return true; +} + +bool ValidatePassword(const std::string& password, std::string& error) { + if (password.size() < 6 || password.size() > 128) { + error = "密码长度必须为 6-128 位"; + return false; + } + return true; +} + +AuthUser ToAuthUser(const AccountRecord& account) { + return AuthUser{ + account.username, + account.is_admin, + account.expires_on, + account.enabled, + }; +} + +std::optional FindAccount(sqlite3* database, const std::string& username) { + Statement statement(database, + "SELECT username, password_salt, password_hash, is_admin, expires_on, created_at, " + "enabled " + "FROM accounts WHERE username = ?"); + if (!statement) { + return std::nullopt; + } + sqlite3_bind_text(statement.Get(), 1, username.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(statement.Get()) != SQLITE_ROW) { + return std::nullopt; + } + return AccountRecord{ + ColumnText(statement.Get(), 0), + ColumnText(statement.Get(), 1), + ColumnText(statement.Get(), 2), + sqlite3_column_int(statement.Get(), 3) != 0, + ColumnText(statement.Get(), 4), + ColumnText(statement.Get(), 5), + sqlite3_column_int(statement.Get(), 6) != 0, + }; +} + +bool HasColumn(sqlite3* database, const std::string& table, const std::string& column) { + Statement statement(database, ("PRAGMA table_info(" + table + ")").c_str()); + if (!statement) { + return false; + } + while (sqlite3_step(statement.Get()) == SQLITE_ROW) { + if (ColumnText(statement.Get(), 1) == column) { + return true; + } + } + return false; +} + +} // namespace + +AuthManager& AuthManager::Instance() { + static AuthManager instance; + return instance; +} + +AuthManager::~AuthManager() { + if (database_ != nullptr) { + sqlite3_close(database_); + } +} + +bool AuthManager::Initialize() { + std::lock_guard lock(mutex_); + return OpenDatabaseLocked() && CreateSchemaLocked() && BootstrapAdminLocked(); +} + +bool AuthManager::OpenDatabaseLocked() { + if (database_ != nullptr) { + return true; + } + fs::create_directories(kDatabasePath.parent_path()); + if (sqlite3_open_v2(kDatabasePath.c_str(), + &database_, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nullptr) != SQLITE_OK) { + return false; + } + sqlite3_busy_timeout(database_, 5000); + return sqlite3_exec(database_, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr) == + SQLITE_OK; +} + +bool AuthManager::CreateSchemaLocked() { + constexpr const char* kSchema = + "CREATE TABLE IF NOT EXISTS accounts (" + "username TEXT PRIMARY KEY NOT NULL," + "password_salt TEXT NOT NULL," + "password_hash TEXT NOT NULL," + "is_admin INTEGER NOT NULL DEFAULT 0," + "expires_on TEXT NOT NULL DEFAULT ''," + "created_at TEXT NOT NULL," + "enabled INTEGER NOT NULL DEFAULT 1" + ");"; + if (sqlite3_exec(database_, kSchema, nullptr, nullptr, nullptr) != SQLITE_OK) { + return false; + } + if (HasColumn(database_, "accounts", "enabled")) { + return true; + } + constexpr const char* kAddEnabled = + "ALTER TABLE accounts ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1"; + return sqlite3_exec(database_, kAddEnabled, nullptr, nullptr, nullptr) == SQLITE_OK; +} + +bool AuthManager::BootstrapAdminLocked() { + if (FindAccount(database_, "admin").has_value()) { + return true; + } + const auto salt = drogon::utils::secureRandomString(32); + const auto password_hash = HashPassword("nwl888888", salt); + const auto created_at = CurrentDateTime(); + Statement statement(database_, + "INSERT INTO accounts " + "(username, password_salt, password_hash, is_admin, expires_on, created_at) " + "VALUES (?, ?, ?, 1, '', ?)"); + if (!statement) { + return false; + } + sqlite3_bind_text(statement.Get(), 1, "admin", -1, SQLITE_STATIC); + sqlite3_bind_text(statement.Get(), 2, salt.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 3, password_hash.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 4, created_at.c_str(), -1, SQLITE_TRANSIENT); + return sqlite3_step(statement.Get()) == SQLITE_DONE; +} + +AuthResult AuthManager::Login(const std::string& username, + const std::string& password, + std::string& token) { + std::lock_guard lock(mutex_); + const auto account = FindAccount(database_, username); + if (!account.has_value() || + !ConstantTimeEquals(HashPassword(password, account->password_salt), + account->password_hash)) { + return {}; + } + if (!account->enabled) { + return {AuthStatus::kDisabled, ToAuthUser(account.value())}; + } + if (IsExpired(account.value())) { + return {AuthStatus::kExpired, ToAuthUser(account.value())}; + } + token = drogon::utils::secureRandomString(48); + sessions_[token] = Session{username, std::chrono::system_clock::now() + kSessionLifetime}; + return {AuthStatus::kAuthenticated, ToAuthUser(account.value())}; +} + +AuthResult AuthManager::Authenticate(const std::string& token) { + std::lock_guard lock(mutex_); + if (token.empty()) { + return {}; + } + const auto session = sessions_.find(token); + if (session == sessions_.end() || session->second.expires_at <= std::chrono::system_clock::now()) { + if (session != sessions_.end()) { + sessions_.erase(session); + } + return {}; + } + const auto account = FindAccount(database_, session->second.username); + if (!account.has_value()) { + sessions_.erase(session); + return {}; + } + if (!account->enabled) { + sessions_.erase(session); + return {AuthStatus::kDisabled, ToAuthUser(account.value())}; + } + if (IsExpired(account.value())) { + sessions_.erase(session); + return {AuthStatus::kExpired, ToAuthUser(account.value())}; + } + return {AuthStatus::kAuthenticated, ToAuthUser(account.value())}; +} + +void AuthManager::Logout(const std::string& token) { + std::lock_guard lock(mutex_); + sessions_.erase(token); +} + +nlohmann::json AuthManager::ListAccounts() { + std::lock_guard lock(mutex_); + json result = json::array(); + Statement statement(database_, + "SELECT username, is_admin, expires_on, created_at, enabled " + "FROM accounts ORDER BY is_admin DESC, username"); + if (!statement) { + return result; + } + while (sqlite3_step(statement.Get()) == SQLITE_ROW) { + AccountRecord account; + account.username = ColumnText(statement.Get(), 0); + account.is_admin = sqlite3_column_int(statement.Get(), 1) != 0; + account.expires_on = ColumnText(statement.Get(), 2); + account.created_at = ColumnText(statement.Get(), 3); + account.enabled = sqlite3_column_int(statement.Get(), 4) != 0; + result.push_back({ + {"username", account.username}, + {"is_admin", account.is_admin}, + {"expires_on", account.expires_on}, + {"permanent", account.expires_on.empty()}, + {"enabled", account.enabled}, + {"created_at", account.created_at}, + {"expired", IsExpired(account)}, + }); + } + return result; +} + +bool AuthManager::CreateAccount(const std::string& username, + const std::string& password, + const std::string& expires_on, + std::string& error) { + std::lock_guard lock(mutex_); + if (!ValidateUsername(username, error) || !ValidatePassword(password, error)) { + return false; + } + if (!expires_on.empty() && + (!IsValidDate(expires_on) || expires_on < CurrentDate())) { + error = "有效期必须是今天或之后的有效日期"; + return false; + } + if (FindAccount(database_, username).has_value()) { + error = "账号已存在"; + return false; + } + const auto salt = drogon::utils::secureRandomString(32); + const auto password_hash = HashPassword(password, salt); + const auto created_at = CurrentDateTime(); + Statement statement(database_, + "INSERT INTO accounts " + "(username, password_salt, password_hash, is_admin, expires_on, created_at) " + "VALUES (?, ?, ?, 0, ?, ?)"); + if (!statement) { + error = "保存账号失败"; + return false; + } + sqlite3_bind_text(statement.Get(), 1, username.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 2, salt.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 3, password_hash.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 4, expires_on.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 5, created_at.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(statement.Get()) != SQLITE_DONE) { + error = "保存账号失败"; + return false; + } + return true; +} + +bool AuthManager::UpdateAccount(const std::string& username, + const std::optional& password, + const std::string& expires_on, + const std::optional& enabled, + std::string& error) { + std::lock_guard lock(mutex_); + const auto account = FindAccount(database_, username); + if (!account.has_value()) { + error = "账号不存在"; + return false; + } + if (account->is_admin) { + error = "内置管理员账号不能修改有效期"; + return false; + } + if (!expires_on.empty() && !IsValidDate(expires_on)) { + error = "请输入有效日期"; + return false; + } + if (password.has_value() && !password->empty() && + !ValidatePassword(password.value(), error)) { + return false; + } + std::string salt = account->password_salt; + std::string password_hash = account->password_hash; + const bool account_enabled = enabled.value_or(account->enabled); + if (password.has_value() && !password->empty()) { + salt = drogon::utils::secureRandomString(32); + password_hash = HashPassword(password.value(), salt); + } + Statement statement(database_, + "UPDATE accounts SET password_salt = ?, password_hash = ?, expires_on = ?, " + "enabled = ? WHERE username = ?"); + if (!statement) { + error = "保存账号失败"; + return false; + } + sqlite3_bind_text(statement.Get(), 1, salt.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 2, password_hash.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement.Get(), 3, expires_on.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(statement.Get(), 4, account_enabled ? 1 : 0); + sqlite3_bind_text(statement.Get(), 5, username.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(statement.Get()) != SQLITE_DONE) { + error = "保存账号失败"; + return false; + } + InvalidateUserSessionsLocked(username); + return true; +} + +void AuthManager::InvalidateUserSessionsLocked(const std::string& username) { + for (auto iterator = sessions_.begin(); iterator != sessions_.end();) { + if (iterator->second.username == username) { + iterator = sessions_.erase(iterator); + } else { + ++iterator; + } + } +} diff --git a/backend/src/auth/AuthManager.h b/backend/src/auth/AuthManager.h new file mode 100644 index 0000000..fc996b6 --- /dev/null +++ b/backend/src/auth/AuthManager.h @@ -0,0 +1,76 @@ +#ifndef AUTHMANAGER_H +#define AUTHMANAGER_H + +#include +#include +#include +#include +#include + +#include + +struct sqlite3; + +struct AuthUser { + std::string username; + bool is_admin = false; + std::string expires_on; + bool enabled = true; +}; + +enum class AuthStatus { + kAuthenticated, + kUnauthenticated, + kExpired, + kDisabled, +}; + +struct AuthResult { + AuthStatus status = AuthStatus::kUnauthenticated; + AuthUser user; +}; + +class AuthManager { +public: + static constexpr const char* kSessionCookie = "wind_session"; + + static AuthManager& Instance(); + + bool Initialize(); + AuthResult Login(const std::string& username, + const std::string& password, + std::string& token); + AuthResult Authenticate(const std::string& token); + void Logout(const std::string& token); + + nlohmann::json ListAccounts(); + bool CreateAccount(const std::string& username, + const std::string& password, + const std::string& expires_on, + std::string& error); + bool UpdateAccount(const std::string& username, + const std::optional& password, + const std::string& expires_on, + const std::optional& enabled, + std::string& error); + +private: + struct Session { + std::string username; + std::chrono::system_clock::time_point expires_at; + }; + + AuthManager() = default; + ~AuthManager(); + + bool OpenDatabaseLocked(); + bool CreateSchemaLocked(); + bool BootstrapAdminLocked(); + void InvalidateUserSessionsLocked(const std::string& username); + + std::mutex mutex_; + sqlite3* database_ = nullptr; + std::unordered_map sessions_; +}; + +#endif diff --git a/backend/src/controllers/AuthController.cpp b/backend/src/controllers/AuthController.cpp new file mode 100644 index 0000000..174ad29 --- /dev/null +++ b/backend/src/controllers/AuthController.cpp @@ -0,0 +1,185 @@ +#include "AuthController.h" + +#include + +#include +#include + +#include +#include + +#include "auth/AuthManager.h" +#include "utils/ResponseUtil.h" + +namespace { + +using json = nlohmann::json; + +std::optional ParseBody(const drogon::HttpRequestPtr& req) { + try { + return json::parse(req->body()); + } catch (const std::exception&) { + return std::nullopt; + } +} + +json UserJson(const AuthUser& user) { + return { + {"username", user.username}, + {"is_admin", user.is_admin}, + {"expires_on", user.expires_on}, + {"permanent", user.expires_on.empty()}, + {"enabled", user.enabled}, + }; +} + +void SendAuthError(DrogonCallback& callback, const AuthResult& auth) { + if (auth.status == AuthStatus::kExpired || auth.status == AuthStatus::kDisabled) { + SendError(callback, 4, "账号异常请联系管理员", drogon::k401Unauthorized); + } else { + SendError(callback, 2, "请先登录", drogon::k401Unauthorized); + } +} + +std::optional RequireAdmin(const drogon::HttpRequestPtr& req, + DrogonCallback& callback) { + const auto auth = AuthManager::Instance().Authenticate( + req->getCookie(AuthManager::kSessionCookie)); + if (auth.status != AuthStatus::kAuthenticated) { + SendAuthError(callback, auth); + return std::nullopt; + } + if (!auth.user.is_admin) { + SendForbidden(callback, "仅管理员可管理账号"); + return std::nullopt; + } + return auth.user; +} + +void AddSessionCookie(const drogon::HttpResponsePtr& response, + const std::string& token, + int max_age) { + drogon::Cookie cookie(AuthManager::kSessionCookie, token); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setSameSite(drogon::Cookie::SameSite::kLax); + cookie.setMaxAge(max_age); + response->addCookie(std::move(cookie)); +} + +drogon::HttpResponsePtr MakeJsonResponse(const json& body) { + auto response = drogon::HttpResponse::newHttpResponse(); + response->setContentTypeCode(drogon::CT_APPLICATION_JSON); + response->setBody(body.dump()); + return response; +} + +} // namespace + +void AuthController::Login( + const drogon::HttpRequestPtr& req, + std::function&& callback) { + const auto body = ParseBody(req); + if (!body.has_value() || !body->is_object()) { + SendError(callback, 1, "登录参数格式错误"); + return; + } + const auto username = body->value("username", ""); + const auto password = body->value("password", ""); + std::string token; + const auto auth = AuthManager::Instance().Login(username, password, token); + if (auth.status == AuthStatus::kExpired || auth.status == AuthStatus::kDisabled) { + SendError(callback, 4, "账号异常请联系管理员", drogon::k401Unauthorized); + return; + } + if (auth.status != AuthStatus::kAuthenticated) { + SendError(callback, 2, "账号或密码错误", drogon::k401Unauthorized); + return; + } + auto response = MakeJsonResponse( + ResponseUtil::GenerateSuccessResponse(UserJson(auth.user))); + AddSessionCookie(response, token, 12 * 60 * 60); + callback(response); +} + +void AuthController::Logout( + const drogon::HttpRequestPtr& req, + std::function&& callback) { + AuthManager::Instance().Logout(req->getCookie(AuthManager::kSessionCookie)); + auto response = MakeJsonResponse(ResponseUtil::GenerateSuccessResponse()); + AddSessionCookie(response, "", 0); + callback(response); +} + +void AuthController::GetCurrentUser( + const drogon::HttpRequestPtr& req, + std::function&& callback) { + const auto auth = AuthManager::Instance().Authenticate( + req->getCookie(AuthManager::kSessionCookie)); + if (auth.status != AuthStatus::kAuthenticated) { + SendAuthError(callback, auth); + return; + } + SendSuccess(callback, UserJson(auth.user)); +} + +void AuthController::ListAccounts( + const drogon::HttpRequestPtr& req, + std::function&& callback) { + if (!RequireAdmin(req, callback).has_value()) { + return; + } + SendSuccess(callback, json{{"accounts", AuthManager::Instance().ListAccounts()}}); +} + +void AuthController::CreateAccount( + const drogon::HttpRequestPtr& req, + std::function&& callback) { + if (!RequireAdmin(req, callback).has_value()) { + return; + } + const auto body = ParseBody(req); + if (!body.has_value() || !body->is_object()) { + SendError(callback, 1, "账号参数格式错误"); + return; + } + std::string error; + if (!AuthManager::Instance().CreateAccount( + body->value("username", ""), + body->value("password", ""), + body->value("expires_on", ""), + error)) { + SendError(callback, 1, error); + return; + } + SendSuccess(callback); +} + +void AuthController::UpdateAccount( + const drogon::HttpRequestPtr& req, + std::function&& callback, + const std::string& username) { + if (!RequireAdmin(req, callback).has_value()) { + return; + } + const auto body = ParseBody(req); + if (!body.has_value() || !body->is_object()) { + SendError(callback, 1, "账号参数格式错误"); + return; + } + std::optional password; + if (body->contains("password") && (*body)["password"].is_string()) { + password = (*body)["password"].get(); + } + std::optional enabled; + if (body->contains("enabled") && (*body)["enabled"].is_boolean()) { + enabled = (*body)["enabled"].get(); + } + std::string error; + if (!AuthManager::Instance().UpdateAccount( + username, password, body->value("expires_on", ""), enabled, error)) { + SendError(callback, 1, error); + return; + } + SendSuccess(callback); +} diff --git a/backend/src/controllers/AuthController.h b/backend/src/controllers/AuthController.h new file mode 100644 index 0000000..b72b7a1 --- /dev/null +++ b/backend/src/controllers/AuthController.h @@ -0,0 +1,34 @@ +#ifndef AUTHCONTROLLER_H +#define AUTHCONTROLLER_H + +#include + +class AuthController : public drogon::HttpController { +public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(AuthController::Login, "/api/auth/login", drogon::Post); + ADD_METHOD_TO(AuthController::Logout, "/api/auth/logout", drogon::Post); + ADD_METHOD_TO(AuthController::GetCurrentUser, "/api/auth/me", drogon::Get); + ADD_METHOD_TO(AuthController::ListAccounts, "/api/admin/accounts", drogon::Get); + ADD_METHOD_TO(AuthController::CreateAccount, "/api/admin/accounts", drogon::Post); + ADD_METHOD_TO(AuthController::UpdateAccount, + "/api/admin/accounts/{1}", + drogon::Put); + METHOD_LIST_END + + void Login(const drogon::HttpRequestPtr& req, + std::function&& callback); + void Logout(const drogon::HttpRequestPtr& req, + std::function&& callback); + void GetCurrentUser(const drogon::HttpRequestPtr& req, + std::function&& callback); + void ListAccounts(const drogon::HttpRequestPtr& req, + std::function&& callback); + void CreateAccount(const drogon::HttpRequestPtr& req, + std::function&& callback); + void UpdateAccount(const drogon::HttpRequestPtr& req, + std::function&& callback, + const std::string& username); +}; + +#endif diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index ab0cbd9..3af8865 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include @@ -36,11 +38,15 @@ constexpr auto kUploadIdleTimeout = std::chrono::minutes(30); constexpr auto kCompletedJobRetention = std::chrono::hours(24); constexpr const char* kDefaultSchemeId = "scheme_one"; constexpr const char* kSchemeTwoId = "scheme_two"; +constexpr const char* kSchemeThreeId = "scheme_three"; constexpr const char* kSchemeOneName = "方案一"; constexpr const char* kSchemeTwoName = "方案二"; +constexpr const char* kSchemeThreeName = "方案三"; constexpr const char* kSchemeOneDefaultDescription = "通用方案"; constexpr const char* kSchemeTwoDefaultDescription = "桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数"; +constexpr const char* kSchemeThreeDefaultDescription = + "叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比"; struct RawRow { std::string time; @@ -48,6 +54,7 @@ struct RawRow { double wind_speed = 0.0; double active_power = 0.0; double generator_speed = 0.0; + double rotor_speed = 0.0; }; struct ValidRow { @@ -58,6 +65,7 @@ struct ValidRow { double wind_speed = 0.0; double active_power = 0.0; double generator_speed = 0.0; + double rotor_speed = 0.0; double blade_pitch_1 = 0.0; double blade_pitch_2 = 0.0; double blade_pitch_3 = 0.0; @@ -125,6 +133,17 @@ struct SchemeInfo { double rated_generator_speed = 1755.0; double rated_power = 2000.0; double scheme_two_report_wind_speed_interval = 0.25; + double scheme_three_rated_power = 4800.0; + double scheme_three_rated_wind_speed = 14.0; + double scheme_three_power_step = 5.0; + double scheme_three_cleaning_wind_speed_step = 0.25; + double scheme_three_wind_speed_change_threshold = 1.0; + double scheme_three_iqr_lower_multiplier = 1.2; + double scheme_three_iqr_upper_multiplier = 2.0; + double scheme_three_minimum_generator_speed = 1.0; + double scheme_three_generator_speed_k = 0.9; + double scheme_three_rotor_radius = 78.0; + double scheme_three_report_wind_speed_interval = 0.25; }; constexpr double kRatedCornerWindBefore = 0.5; @@ -170,6 +189,14 @@ fs::path JobResultPath(const std::string& job_id) { return JobDir(job_id) / "result.json"; } +fs::path JobPointsDir(const std::string& job_id) { + return JobDir(job_id) / "points"; +} + +fs::path JobFanPointsPath(const std::string& job_id, size_t fan_index) { + return JobPointsDir(job_id) / ("fan_" + std::to_string(fan_index) + ".json"); +} + std::mutex g_task_mutex; std::string g_active_job_id; std::chrono::steady_clock::time_point g_active_since; @@ -279,6 +306,28 @@ std::string FileNameForFan(const std::string& fan_id) { return output.empty() ? "wind_turbine" : output; } +std::string PointIdFor(const json& point, const std::string& fan_id) { + const auto text = [&point](const char* field) { + return point.contains(field) ? JsonText(point[field]) : ""; + }; + const auto point_fan_id = text("fan_id"); + return (point_fan_id.empty() ? fan_id : point_fan_id) + "|" + text("time") + "|" + + text("wind_speed") + "|" + text("active_power") + "|" + text("reason"); +} + +std::unordered_set PointIdSet(const json& body, const char* field) { + std::unordered_set ids; + if (!body.contains(field) || !body[field].is_array()) { + return ids; + } + for (const auto& value : body[field]) { + if (value.is_string()) { + ids.insert(value.get()); + } + } + return ids; +} + fs::path SchemeConfigPath() { return fs::path("data") / "wind_schemes.json"; } @@ -485,7 +534,13 @@ bool SaveChartOptionsToFile(const json& options) { } std::string NormalizeSchemeId(const std::string& scheme_id) { - return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId; + if (scheme_id == kSchemeTwoId) { + return kSchemeTwoId; + } + if (scheme_id == kSchemeThreeId) { + return kSchemeThreeId; + } + return kDefaultSchemeId; } std::vector DefaultSchemes() { @@ -498,7 +553,12 @@ std::vector DefaultSchemes() { scheme_two.id = kSchemeTwoId; scheme_two.name = kSchemeTwoName; scheme_two.description = kSchemeTwoDefaultDescription; - return {scheme_one, scheme_two}; + + SchemeInfo scheme_three; + scheme_three.id = kSchemeThreeId; + scheme_three.name = kSchemeThreeName; + scheme_three.description = kSchemeThreeDefaultDescription; + return {scheme_one, scheme_two, scheme_three}; } std::optional FindScheme(const std::vector& schemes, @@ -538,6 +598,20 @@ json SchemeToJson(const SchemeInfo& scheme) { data["parameters"]["rated_power"] = scheme.rated_power; data["parameters"]["report_wind_speed_interval"] = scheme.scheme_two_report_wind_speed_interval; + } else if (scheme.id == kSchemeThreeId) { + data["parameters"] = { + {"rated_power", scheme.scheme_three_rated_power}, + {"rated_wind_speed", scheme.scheme_three_rated_wind_speed}, + {"power_step", scheme.scheme_three_power_step}, + {"cleaning_wind_speed_step", scheme.scheme_three_cleaning_wind_speed_step}, + {"wind_speed_change_threshold", scheme.scheme_three_wind_speed_change_threshold}, + {"iqr_lower_multiplier", scheme.scheme_three_iqr_lower_multiplier}, + {"iqr_upper_multiplier", scheme.scheme_three_iqr_upper_multiplier}, + {"minimum_generator_speed", scheme.scheme_three_minimum_generator_speed}, + {"generator_speed_k", scheme.scheme_three_generator_speed_k}, + {"rotor_radius", scheme.scheme_three_rotor_radius}, + {"report_wind_speed_interval", scheme.scheme_three_report_wind_speed_interval}, + }; } return data; } @@ -615,6 +689,40 @@ std::vector LoadSchemes() { value.has_value() && value.value() > 0.0 && value.value() <= 2.0) { scheme.scheme_two_report_wind_speed_interval = value.value(); } + } else if (scheme.id == kSchemeThreeId) { + const auto load_positive = [&](const std::string& field, double& target) { + if (const auto value = GetNumberField(params, field); + value.has_value() && std::isfinite(value.value()) && + value.value() > 0.0) { + target = value.value(); + } + }; + const auto load_non_negative = [&](const std::string& field, + double& target) { + if (const auto value = GetNumberField(params, field); + value.has_value() && std::isfinite(value.value()) && + value.value() >= 0.0) { + target = value.value(); + } + }; + load_positive("rated_power", scheme.scheme_three_rated_power); + load_positive("rated_wind_speed", scheme.scheme_three_rated_wind_speed); + load_positive("power_step", scheme.scheme_three_power_step); + load_positive("cleaning_wind_speed_step", + scheme.scheme_three_cleaning_wind_speed_step); + load_non_negative("wind_speed_change_threshold", + scheme.scheme_three_wind_speed_change_threshold); + load_non_negative("iqr_lower_multiplier", + scheme.scheme_three_iqr_lower_multiplier); + load_non_negative("iqr_upper_multiplier", + scheme.scheme_three_iqr_upper_multiplier); + load_non_negative("minimum_generator_speed", + scheme.scheme_three_minimum_generator_speed); + load_non_negative("generator_speed_k", + scheme.scheme_three_generator_speed_k); + load_positive("rotor_radius", scheme.scheme_three_rotor_radius); + load_positive("report_wind_speed_interval", + scheme.scheme_three_report_wind_speed_interval); } } } @@ -713,6 +821,53 @@ std::optional ParseTime(std::string value) { return std::mktime(&tm); } +std::string NormalizeRawTime(const json& value) { + if (value.is_number()) { + // Excel serial dates use 1899-12-30 as the practical epoch. + const auto seconds = static_cast((value.get() - 25569.0) * 86400.0); + std::tm tm = {}; + localtime_r(&seconds, &tm); + std::ostringstream output; + output << std::put_time(&tm, "%Y-%m-%d %H:%M:%S"); + return output.str(); + } + return JsonText(value); +} + +std::optional BuildStandardRow(const json& raw_row, const json& metadata) { + if (!raw_row.is_object() || !raw_row.contains("values") || !raw_row["values"].is_array()) { + return std::nullopt; + } + const auto headers = metadata.value("raw_headers", json::array()); + const auto mapping = metadata.value("mapping", json::object()); + if (!headers.is_array() || !mapping.is_object()) { + return std::nullopt; + } + const auto& values = raw_row["values"]; + json row = json::object(); + for (const char* field : {"time", "fan_id", "wind_speed", "active_power", "generator_speed", + "rotor_speed", "blade_pitch_1", "blade_pitch_2", "blade_pitch_3"}) { + const auto header = mapping.value(field, ""); + size_t index = headers.size(); + for (size_t i = 0; i < headers.size(); ++i) { + if (headers[i].is_string() && headers[i].get() == header) { + index = i; + break; + } + } + if (index >= values.size()) { + row[field] = nullptr; + } else if (std::string(field) == "time") { + row[field] = NormalizeRawTime(values[index]); + } else if (std::string(field) == "fan_id") { + row[field] = Trim(JsonText(values[index])); + } else { + row[field] = values[index]; + } + } + return row; +} + json CounterJson(const std::unordered_map& counters) { json data = json::object(); for (const auto& item : counters) { @@ -851,6 +1006,10 @@ bool IsSchemeTwo(const CalculationOptions& options) { return options.scheme_id == kSchemeTwoId; } +bool IsSchemeThree(const CalculationOptions& options) { + return options.scheme_id == kSchemeThreeId; +} + void AddInvalid(std::unordered_map& counters, const std::string& reason) { counters[reason] += 1; } @@ -1021,18 +1180,21 @@ std::vector FilterByWindBinIqr(const std::vector& rows, max_wind = std::max(max_wind, row.wind_speed); } + std::map> buckets; + for (const auto& row : rows) { + const auto bucket = static_cast(std::floor( + (row.wind_speed - min_wind) / options.cleaning_wind_speed_step + 1e-9)); + buckets[bucket].push_back(row); + } + std::vector result; result.reserve(rows.size()); - for (double interval = min_wind; interval <= max_wind; - interval += options.cleaning_wind_speed_step) { - std::vector interval_rows; + for (const auto& item : buckets) { + const auto& interval_rows = item.second; std::vector values; - for (const auto& row : rows) { - if (row.wind_speed >= interval && - row.wind_speed < interval + options.cleaning_wind_speed_step) { - interval_rows.push_back(row); - values.push_back(value_getter(row)); - } + values.reserve(interval_rows.size()); + for (const auto& row : interval_rows) { + values.push_back(value_getter(row)); } if (interval_rows.empty()) { @@ -1074,18 +1236,22 @@ std::vector BuildMedianCurveBins(const std::vector& rows, const double curve_min = 1.0 - wind_speed_step * 0.5; const double curve_max = 25.0 + wind_speed_step * 0.5; - std::vector bins; - for (double start = curve_min; start < curve_max; start += wind_speed_step) { - const double end = start + wind_speed_step; - std::vector values; - for (const auto& row : rows) { - if (row.wind_speed > start && row.wind_speed <= end) { - values.push_back(row.active_power); - } + const auto bucket_count = static_cast(std::ceil((curve_max - curve_min) / wind_speed_step)); + std::vector> values_by_bucket(bucket_count); + for (const auto& row : rows) { + const auto bucket = static_cast(std::ceil((row.wind_speed - curve_min) / + wind_speed_step) - 1.0); + if (bucket >= 0 && static_cast(bucket) < values_by_bucket.size()) { + values_by_bucket[static_cast(bucket)].push_back(row.active_power); } + } + std::vector bins; + for (size_t index = 0; index < values_by_bucket.size(); ++index) { + const auto& values = values_by_bucket[index]; if (!values.empty()) { CurveBin bin; - bin.wind_speed = (start + end) / 2.0; + const double start = curve_min + static_cast(index) * wind_speed_step; + bin.wind_speed = start + wind_speed_step / 2.0; bin.median_power = Quantile(values, 0.5); bin.sample_count = values.size(); bins.push_back(bin); @@ -1371,14 +1537,14 @@ void WindPowerController::SaveSchemeDescription( return; } - std::optional scheme_one_parameters; + std::optional scheme_base_parameters; std::optional grid_connected_speed; std::optional rated_generator_speed; std::optional rated_power; std::optional report_wind_speed_interval; - if (normalized_id == kDefaultSchemeId) { + if (normalized_id == kDefaultSchemeId || normalized_id == kSchemeThreeId) { if (!body.value().contains("parameters") || !body.value()["parameters"].is_object()) { - SendError(callback, kErrorInvalidRequest, "方案一参数格式错误"); + SendError(callback, kErrorInvalidRequest, "方案参数格式错误"); return; } const auto& params = body.value()["parameters"]; @@ -1392,24 +1558,25 @@ void WindPowerController::SaveSchemeDescription( }; if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") || !valid_positive("power_step") || !valid_positive("cleaning_wind_speed_step") || - !valid_positive("rotor_radius") || !valid_positive("gearbox_ratio") || + !valid_positive("rotor_radius") || + (normalized_id == kDefaultSchemeId && !valid_positive("gearbox_ratio")) || !valid_positive("report_wind_speed_interval") || !valid_non_negative("wind_speed_change_threshold") || !valid_non_negative("iqr_lower_multiplier") || !valid_non_negative("iqr_upper_multiplier") || !valid_non_negative("minimum_generator_speed") || !valid_non_negative("generator_speed_k")) { - SendError(callback, kErrorInvalidRequest, "方案一参数必须为合法数值"); + SendError(callback, kErrorInvalidRequest, "方案参数必须为合法数值"); return; } if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 || GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 || GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 || GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) { - SendError(callback, kErrorInvalidRequest, "方案一参数超出允许范围"); + SendError(callback, kErrorInvalidRequest, "方案参数超出允许范围"); return; } - scheme_one_parameters = params; + scheme_base_parameters = params; } else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) { if (!body.value()["parameters"].is_object()) { SendError(callback, kErrorInvalidRequest, "方案参数格式错误"); @@ -1436,7 +1603,7 @@ void WindPowerController::SaveSchemeDescription( if (scheme.id == normalized_id) { scheme.description = description.value(); if (scheme.id == kDefaultSchemeId) { - const auto& params = scheme_one_parameters.value(); + const auto& params = scheme_base_parameters.value(); scheme.scheme_one_rated_power = GetDoubleField(params, "rated_power").value(); scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value(); scheme.scheme_one_power_step = GetDoubleField(params, "power_step").value(); @@ -1470,6 +1637,30 @@ void WindPowerController::SaveSchemeDescription( scheme.scheme_two_report_wind_speed_interval = report_wind_speed_interval.value(); } + } else if (scheme.id == kSchemeThreeId) { + const auto& params = scheme_base_parameters.value(); + scheme.scheme_three_rated_power = + GetDoubleField(params, "rated_power").value(); + scheme.scheme_three_rated_wind_speed = + GetDoubleField(params, "rated_wind_speed").value(); + scheme.scheme_three_power_step = + GetDoubleField(params, "power_step").value(); + scheme.scheme_three_cleaning_wind_speed_step = + GetDoubleField(params, "cleaning_wind_speed_step").value(); + scheme.scheme_three_wind_speed_change_threshold = + GetDoubleField(params, "wind_speed_change_threshold").value(); + scheme.scheme_three_iqr_lower_multiplier = + GetDoubleField(params, "iqr_lower_multiplier").value(); + scheme.scheme_three_iqr_upper_multiplier = + GetDoubleField(params, "iqr_upper_multiplier").value(); + scheme.scheme_three_minimum_generator_speed = + GetDoubleField(params, "minimum_generator_speed").value(); + scheme.scheme_three_generator_speed_k = + GetDoubleField(params, "generator_speed_k").value(); + scheme.scheme_three_rotor_radius = + GetDoubleField(params, "rotor_radius").value(); + scheme.scheme_three_report_wind_speed_interval = + GetDoubleField(params, "report_wind_speed_interval").value(); } updated = true; break; @@ -1588,8 +1779,8 @@ void WindPowerController::UploadChunk( SendError(callback, kErrorJobNotFound, "计算任务不存在"); return; } - if (!body->contains("rows") || !(*body)["rows"].is_array()) { - SendError(callback, kErrorInvalidRequest, "缺少 rows 参数"); + if (!body->contains("raw_rows") || !(*body)["raw_rows"].is_array()) { + SendError(callback, kErrorInvalidRequest, "缺少 raw_rows 参数"); return; } if (IsTaskBusyFor(job_id.value())) { @@ -1597,22 +1788,26 @@ void WindPowerController::UploadChunk( return; } try { + json metadata; + { + std::ifstream meta(JobDir(job_id.value()) / "metadata.json"); + meta >> metadata; + } std::ofstream out(JobRowsPath(job_id.value()), std::ios::app); std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app); int accepted = 0; - for (const auto& row : (*body)["rows"]) { - if (!row.is_object()) { + for (const auto& raw_row : (*body)["raw_rows"]) { + if (!raw_row.is_object()) { continue; } - out << row.dump() << '\n'; - ++accepted; - } - out.close(); - if (body->contains("raw_rows") && (*body)["raw_rows"].is_array()) { - for (const auto& row : (*body)["raw_rows"]) { - if (row.is_object()) raw_out << row.dump() << '\n'; + raw_out << raw_row.dump() << '\n'; + const auto row = BuildStandardRow(raw_row, metadata); + if (row.has_value()) { + out << row->dump() << '\n'; + ++accepted; } } + out.close(); raw_out.close(); TouchTask(job_id.value()); @@ -1645,6 +1840,7 @@ void WindPowerController::FinishJob( return; } TaskReleaseGuard finish_guard(job_id.value()); + const auto calculation_started = std::chrono::steady_clock::now(); const CalculationOptions options = ParseOptions(*body); if (body->contains("options") && (*body)["options"].is_object() && @@ -1668,6 +1864,7 @@ void WindPowerController::FinishJob( std::vector parsed_rows; std::unordered_map invalid_reasons; int raw_rows = 0; + const auto read_started = std::chrono::steady_clock::now(); try { std::ifstream in(JobRowsPath(job_id.value())); @@ -1691,6 +1888,7 @@ void WindPowerController::FinishJob( const auto wind_speed = GetDoubleField(row, "wind_speed"); const auto active_power = GetDoubleField(row, "active_power"); const auto generator_speed = GetDoubleField(row, "generator_speed"); + const auto rotor_speed = GetDoubleField(row, "rotor_speed"); const auto blade_pitch_1 = GetDoubleField(row, "blade_pitch_1"); const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2"); const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3"); @@ -1706,6 +1904,12 @@ void WindPowerController::FinishJob( AddInvalid(invalid_reasons, "invalid_generator_speed"); continue; } + if (IsSchemeThree(options) && + (!rotor_speed.has_value() || !std::isfinite(rotor_speed.value()) || + rotor_speed.value() <= 0.0)) { + AddInvalid(invalid_reasons, "invalid_rotor_speed"); + continue; + } if (IsSchemeTwo(options) && (!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) || !blade_pitch_2.has_value() || !std::isfinite(blade_pitch_2.value()) || @@ -1740,6 +1944,9 @@ void WindPowerController::FinishJob( valid_row.wind_speed = wind_speed.value(); valid_row.active_power = active_power.value(); valid_row.generator_speed = generator_speed.value(); + if (rotor_speed.has_value() && std::isfinite(rotor_speed.value())) { + valid_row.rotor_speed = rotor_speed.value(); + } if (IsSchemeTwo(options)) { valid_row.blade_pitch_1 = blade_pitch_1.value(); valid_row.blade_pitch_2 = blade_pitch_2.value(); @@ -1755,6 +1962,8 @@ void WindPowerController::FinishJob( SendError(callback, kErrorServer, "读取任务数据失败"); return; } + const auto read_parse_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - read_started).count(); std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) { if (left.fan_id != right.fan_id) { @@ -1787,8 +1996,7 @@ void WindPowerController::FinishJob( json fans = json::array(); json curves = json::object(); json bins = json::object(); - json scatter_points = json::object(); - json filtered_points = json::object(); + json point_files = json::object(); json estimated_params = json::object(); std::vector fan_ids; @@ -1809,7 +2017,10 @@ void WindPowerController::FinishJob( int scheme_two_low_speed_pitch_count = 0; int scheme_two_low_power_pitch_count = 0; int cleaned_rows_count = 0; - for (const auto& fan_id : fan_ids) { + const auto cleaning_started = std::chrono::steady_clock::now(); + fs::create_directories(JobPointsDir(job_id.value())); + for (size_t fan_index = 0; fan_index < fan_ids.size(); ++fan_index) { + const auto& fan_id = fan_ids[fan_index]; fans.push_back(fan_id); auto fan_rows = rows_by_fan[fan_id]; std::vector fan_removed_points; @@ -1836,9 +2047,11 @@ void WindPowerController::FinishJob( limit_power_count += removed; for (auto& row : fan_rows) { - row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio * - options.rotor_radius * 30.0 / - row.wind_speed; + const double speed = IsSchemeThree(options) + ? row.rotor_speed + : row.generator_speed * options.gearbox_ratio; + row.tip_speed_ratio = speed * 3.14 * options.rotor_radius * 30.0 / + row.wind_speed; } fan_rows = FilterByWindBinIqr( @@ -1913,11 +2126,10 @@ void WindPowerController::FinishJob( point["wind_speed"] = row.wind_speed; point["active_power"] = row.active_power; point["generator_speed"] = row.generator_speed; + point["rotor_speed"] = row.rotor_speed; point["pitch_angle_average"] = row.pitch_angle_average; fan_scatter.push_back(point); } - scatter_points[fan_id] = fan_scatter; - json fan_filtered = json::array(); for (const auto& removed_point : fan_removed_points) { json point; @@ -1926,11 +2138,19 @@ void WindPowerController::FinishJob( point["wind_speed"] = removed_point.row.wind_speed; point["active_power"] = removed_point.row.active_power; point["generator_speed"] = removed_point.row.generator_speed; + point["rotor_speed"] = removed_point.row.rotor_speed; point["pitch_angle_average"] = removed_point.row.pitch_angle_average; point["reason"] = removed_point.reason; fan_filtered.push_back(point); } - filtered_points[fan_id] = fan_filtered; + json point_data; + point_data["fan_id"] = fan_id; + point_data["scatter_points"] = std::move(fan_scatter); + point_data["filtered_points"] = std::move(fan_filtered); + const auto point_path = JobFanPointsPath(job_id.value(), fan_index); + std::ofstream point_out(point_path, std::ios::trunc); + point_out << point_data.dump(); + point_files[fan_id] = point_path.filename().string(); json fan_curve = json::array(); json fan_bins_json = json::array(); @@ -2008,9 +2228,14 @@ void WindPowerController::FinishJob( data["fans"] = fans; data["curves"] = curves; data["bins"] = bins; - data["scatter_points"] = scatter_points; - data["filtered_points"] = filtered_points; + data["point_files"] = point_files; data["estimated_params"] = estimated_params; + data["timings_ms"]["read_parse"] = read_parse_ms; + data["timings_ms"]["cleaning_and_points"] = std::chrono::duration_cast( + std::chrono::steady_clock::now() - cleaning_started).count(); + const auto calculation_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - calculation_started).count(); + data["timings_ms"]["calculation"] = calculation_ms; try { std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc); @@ -2020,7 +2245,49 @@ void WindPowerController::FinishJob( return; } - SendSuccess(callback, data); + json response_data = data; + if (!fan_ids.empty()) { + std::ifstream point_input(JobFanPointsPath(job_id.value(), 0)); + json initial_points; + point_input >> initial_points; + response_data["initial_fan_id"] = fan_ids.front(); + response_data["scatter_points"] = json::object({{fan_ids.front(), initial_points["scatter_points"]}}); + response_data["filtered_points"] = json::object({{fan_ids.front(), initial_points["filtered_points"]}}); + } + LOG_INFO << "wind job " << job_id.value() << " completed in " + << calculation_ms << " ms"; + SendSuccess(callback, response_data); +} + +void WindPowerController::GetFanPoints( + const HttpRequestPtr&, + std::function&& callback, + const std::string& job_id, + const std::string& fan_id) { + if (!IsSafeJobId(job_id) || !fs::exists(JobResultPath(job_id))) { + SendError(callback, kErrorJobNotFound, "计算任务不存在或结果已过期"); + return; + } + try { + json result; + std::ifstream result_input(JobResultPath(job_id)); + result_input >> result; + const auto point_file = result.value("point_files", json::object()).value(fan_id, ""); + if (point_file.empty() || point_file.find("..") != std::string::npos) { + SendError(callback, kErrorInvalidRequest, "风机不存在"); + return; + } + std::ifstream point_input(JobPointsDir(job_id) / point_file); + if (!point_input.good()) { + SendError(callback, kErrorServer, "风机点数据不可用"); + return; + } + json data; + point_input >> data; + SendSuccess(callback, data); + } catch (const std::exception&) { + SendError(callback, kErrorServer, "读取风机点数据失败"); + } } void WindPowerController::ExportReport( @@ -2038,8 +2305,7 @@ void WindPowerController::ExportReport( return; } const auto fan_id = GetStringField(*body, "fan_id"); - if (!fan_id.has_value() || !body->contains("effective_rows") || - !(*body)["effective_rows"].is_array() || !body->contains("report_rows") || + if (!fan_id.has_value() || !body->contains("report_rows") || !(*body)["report_rows"].is_array()) { SendError(callback, kErrorInvalidRequest, "报告参数不完整"); return; @@ -2049,6 +2315,22 @@ void WindPowerController::ExportReport( return; } TaskReleaseGuard report_guard(job_id); + const auto export_started = std::chrono::steady_clock::now(); + + const auto cache_key = std::to_string(std::hash{}(body->dump())); + const auto report_path = JobDir(job_id) / ( + "report_" + FileNameForFan(fan_id.value()) + "_" + cache_key + ".xlsx"); + if (fs::exists(report_path)) { + const auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - export_started).count(); + auto response = HttpResponse::newFileResponse(report_path.string(), + "完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx", + CT_CUSTOM, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response->addHeader("Server-Timing", "report-cache;dur=" + std::to_string(elapsed_ms)); + callback(response); + return; + } try { json metadata; @@ -2075,7 +2357,26 @@ void WindPowerController::ExportReport( return; } - const auto report_path = JobDir(job_id) / ("report_" + FileNameForFan(fan_id.value()) + ".xlsx"); + const auto point_file = result.value("point_files", json::object()).value(fan_id.value(), ""); + if (point_file.empty() || point_file.find("..") != std::string::npos) { + SendError(callback, kErrorJobNotFound, "风机点数据不存在或已过期"); + return; + } + json point_data; + { std::ifstream input(JobPointsDir(job_id) / point_file); input >> point_data; } + const auto restored_ids = PointIdSet(*body, "restored_point_ids"); + const auto erased_ids = PointIdSet(*body, "erased_point_ids"); + json effective_rows = json::array(); + for (const auto& point : point_data.value("scatter_points", json::array())) { + if (point.is_object() && erased_ids.count(PointIdFor(point, fan_id.value())) == 0) { + effective_rows.push_back(point); + } + } + for (const auto& point : point_data.value("filtered_points", json::array())) { + if (point.is_object() && restored_ids.count(PointIdFor(point, fan_id.value())) != 0) { + effective_rows.push_back(point); + } + } lxw_workbook_options options{}; const auto temp_dir = JobDir(job_id).string(); options.constant_memory = LXW_TRUE; @@ -2092,23 +2393,41 @@ void WindPowerController::ExportReport( lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据"); worksheet_freeze_panes(detail, 1, 0); - const bool include_pitch = result.value("scheme", json::object()).value("id", "") == kSchemeTwoId; - const std::vector detail_headers = include_pitch - ? std::vector{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"} - : std::vector{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"}; + const auto report_scheme_id = + result.value("scheme", json::object()).value("id", ""); + const bool include_pitch = report_scheme_id == kSchemeTwoId; + const bool include_rotor_speed = report_scheme_id == kSchemeThreeId; + std::vector detail_headers = + {"风机编号", "采样时间", "平均功率", "平均转速"}; + if (include_rotor_speed) { + detail_headers.push_back("叶轮转速"); + } + detail_headers.push_back("平均风速"); + if (include_pitch) { + detail_headers.push_back("3个叶片变桨角平均值"); + } for (size_t i = 0; i < detail_headers.size(); ++i) { worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format); worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr); } lxw_row_t detail_row = 1; - for (const auto& point : (*body)["effective_rows"]) { + for (const auto& point : effective_rows) { if (!point.is_object()) continue; worksheet_write_string(detail, detail_row, 0, point.value("fan_id", fan_id.value()).c_str(), nullptr); worksheet_write_string(detail, detail_row, 1, point.value("time", "").c_str(), nullptr); worksheet_write_number(detail, detail_row, 2, point.value("active_power", 0.0), number_format); worksheet_write_number(detail, detail_row, 3, point.value("generator_speed", 0.0), number_format); - worksheet_write_number(detail, detail_row, 4, point.value("wind_speed", 0.0), number_format); - if (include_pitch) worksheet_write_number(detail, detail_row, 5, point.value("pitch_angle_average", 0.0), number_format); + lxw_col_t detail_column = 4; + if (include_rotor_speed) { + worksheet_write_number(detail, detail_row, detail_column++, + point.value("rotor_speed", 0.0), number_format); + } + worksheet_write_number(detail, detail_row, detail_column++, + point.value("wind_speed", 0.0), number_format); + if (include_pitch) { + worksheet_write_number(detail, detail_row, detail_column, + point.value("pitch_angle_average", 0.0), number_format); + } ++detail_row; } worksheet_autofilter(detail, 0, 0, std::max(1, detail_row - 1), detail_headers.size() - 1); @@ -2163,7 +2482,12 @@ void WindPowerController::ExportReport( worksheet_write_number(curve, curve_row, 1, point.value("wind_speed", 0.0), number_format); const auto frequency = "=(COUNTIFS('筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + ")/COUNT('筛选前的数据'!" + raw_wind + ":" + raw_wind + "))*8760"; worksheet_write_formula(curve, curve_row, 2, frequency.c_str(), number_format); - const auto actual = "=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!$E:$E,\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选后的数据'!$E:$E,\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + "),0)"; + const auto detail_wind_column = include_rotor_speed ? "$F:$F" : "$E:$E"; + const auto actual = std::string( + "=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!") + + detail_wind_column + ",\">=\"&B" + std::to_string(excel_row) + "-" + + interval_text + ",'筛选后的数据'!" + detail_wind_column + ",\"<\"&B" + + std::to_string(excel_row) + "+" + interval_text + "),0)"; worksheet_write_formula(curve, curve_row, 3, actual.c_str(), number_format); worksheet_write_number(curve, curve_row, 4, point.value("design_power", 0.0), number_format); const auto generated = "=IFERROR(ROUND(C" + std::to_string(excel_row) + "*D" + std::to_string(excel_row) + "/1000,4),0)"; @@ -2195,10 +2519,16 @@ void WindPowerController::ExportReport( &image_options); } if (workbook_close(workbook) != LXW_NO_ERROR) throw std::runtime_error("写入 Excel 文件失败"); - callback(HttpResponse::newFileResponse(report_path.string(), + const auto elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - export_started).count(); + LOG_INFO << "wind report " << job_id << "/" << fan_id.value() + << " generated in " << elapsed_ms << " ms, rows=" << raw_row; + auto response = HttpResponse::newFileResponse(report_path.string(), "完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx", CT_CUSTOM, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response->addHeader("Server-Timing", "report-generate;dur=" + std::to_string(elapsed_ms)); + callback(response); } catch (const std::exception&) { SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试"); } diff --git a/backend/src/controllers/WindPowerController.h b/backend/src/controllers/WindPowerController.h index c11e51d..5d28925 100644 --- a/backend/src/controllers/WindPowerController.h +++ b/backend/src/controllers/WindPowerController.h @@ -14,6 +14,8 @@ public: ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post); ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", Post); ADD_METHOD_TO(WindPowerController::FinishJob, "/api/wind/jobs/finish", Post); + ADD_METHOD_TO(WindPowerController::GetFanPoints, + "/api/wind/jobs/{1}/fans/{2}/points", Get); ADD_METHOD_TO(WindPowerController::ExportReport, "/api/wind/jobs/{1}/report", Post); ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete); ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get); @@ -39,6 +41,10 @@ public: std::function&& callback); void FinishJob(const HttpRequestPtr& req, std::function&& callback); + void GetFanPoints(const HttpRequestPtr& req, + std::function&& callback, + const std::string& job_id, + const std::string& fan_id); void ExportReport(const HttpRequestPtr& req, std::function&& callback, const std::string& job_id); diff --git a/backend/src/main.cpp b/backend/src/main.cpp index d99dba4..6902e2f 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -2,19 +2,59 @@ #include #include +#include +#include "auth/AuthManager.h" +#include "controllers/AuthController.h" #include "controllers/WindPowerController.h" +#include "utils/ResponseUtil.h" using namespace drogon; int main() { - // 抑制 Drogon/trantor 内部日志噪声 - trantor::Logger::setLogLevel(trantor::Logger::kFatal); + // 保留任务耗时日志,便于定位上传、清洗与回传瓶颈。 + trantor::Logger::setLogLevel(trantor::Logger::kInfo); // 加载 Drogon 配置(监听端口 / CORS / 静态资源根目录) LOG_INFO << "Loading server configuration..."; app().loadConfigFile("config/server_config.json"); + if (!AuthManager::Instance().Initialize()) { + LOG_ERROR << "Failed to initialize account database"; + return 1; + } + app().registerController(std::make_shared()); app().registerController(std::make_shared()); + app().registerPreRoutingAdvice( + [](const HttpRequestPtr& req, + AdviceCallback&& stop, + AdviceChainCallback&& next) { + const std::string path = req->path(); + const bool public_api = path == "/api/auth/login" || + path == "/api/auth/logout" || path == "/api/auth/me" || + path == "/api/system/health" || path == "/api/system/version"; + if (req->method() == Options || path.rfind("/api/", 0) != 0 || public_api) { + next(); + return; + } + const auto auth = AuthManager::Instance().Authenticate( + req->getCookie(AuthManager::kSessionCookie)); + if (auth.status == AuthStatus::kAuthenticated) { + next(); + return; + } + const bool account_abnormal = auth.status == AuthStatus::kExpired || + auth.status == AuthStatus::kDisabled; + const auto message = account_abnormal + ? "账号异常请联系管理员" + : "请先登录"; + const auto code = account_abnormal ? 4 : 2; + auto response = HttpResponse::newHttpResponse(); + response->setContentTypeCode(CT_APPLICATION_JSON); + response->setBody( + ResponseUtil::GenerateErrorResponse(code, message).dump()); + response->setStatusCode(k401Unauthorized); + stop(response); + }); // SPA 前端路由回退:未匹配路径统一返回 index.html,交由前端路由处理 app().setCustom404Page( diff --git a/deploy.sh b/deploy.sh index 458c78a..a8312df 100755 --- a/deploy.sh +++ b/deploy.sh @@ -109,7 +109,7 @@ SERVICE_NAME="${SERVICE_NAME}" mkdir -p "$HOME/wind_power/logs" sudo cp "/tmp/${SERVICE_NAME}.service" "/etc/systemd/system/${SERVICE_NAME}.service" sudo apt-get update -qq 2>/dev/null || true -sudo apt-get install -y -qq libjsoncpp25 libc-ares2 >/dev/null 2>&1 || true +sudo apt-get install -y -qq libjsoncpp25 libc-ares2 libsqlite3-0 >/dev/null 2>&1 || true sudo systemctl daemon-reload sudo systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true sudo systemctl restart "${SERVICE_NAME}" diff --git a/docs/1#原始数据_numeric.xlsx b/docs/1#原始数据_numeric.xlsx new file mode 100644 index 0000000..bedb9cd Binary files /dev/null and b/docs/1#原始数据_numeric.xlsx differ diff --git a/frontend/web_app/public/logo-1.svg b/frontend/web_app/public/logo-1.svg new file mode 100644 index 0000000..abf7b0f --- /dev/null +++ b/frontend/web_app/public/logo-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index fc0f108..d30e98e 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -20,6 +20,45 @@ gap: 10px; } +.currentSchemeBanner { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 18px; + padding: 13px 16px; + border: 1px solid #c7d2fe; + border-radius: 10px; + background: #eef2ff; +} + +.currentSchemeLabel { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; +} + +.currentSchemeLabel span { + color: #64748b; + font-size: 13px; +} + +.currentSchemeLabel strong { + padding: 4px 10px; + border-radius: 999px; + background: #4f46e5; + color: #ffffff; + font-size: 14px; + font-weight: 650; +} + +.currentSchemeBanner p { + min-width: 0; + color: #475569; + font-size: 13px; + line-height: 1.5; +} + .homeHeader h1 { color: #fff; font-size: 28px; @@ -475,6 +514,95 @@ select:focus { margin-top: 16px; } +.calculationProgress { + margin-top: 16px; + padding: 14px; + border: 1px solid #c7d2fe; + border-radius: 8px; + background: #f5f7ff; +} + +.calculationProgress.complete { + border-color: #a7f3d0; + background: #f0fdf4; +} + +.calculationProgressHeader { + display: flex; + align-items: center; + justify-content: space-between; + color: #334155; + font-size: 13px; +} + +.calculationProgressHeader span { + color: #4f46e5; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.calculationSteps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.calculationStep { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: #94a3b8; + font-size: 12px; +} + +.calculationStep i { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: #e2e8f0; + color: #64748b; + font-style: normal; + font-weight: 700; +} + +.calculationStep.active { color: #4338ca; font-weight: 600; } +.calculationStep.active i { background: #6366f1; color: #ffffff; } +.calculationStep.completed { color: #047857; } +.calculationStep.completed i, +.calculationProgress.complete .calculationStep i { background: #10b981; color: #ffffff; } + +.calculationProgressTrack { + height: 7px; + margin-top: 12px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.calculationProgressTrack span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #6366f1, #8b5cf6); + transition: width 0.2s ease; +} + +.calculationProgress.complete .calculationProgressTrack span { background: #10b981; } +.calculationProgress p { margin: 9px 0 0; color: #475569; font-size: 13px; } + +.calculationTiming { + margin-top: 10px; + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + .headerPreview { display: flex; flex-wrap: wrap; @@ -770,6 +898,12 @@ td { } @media (max-width: 560px) { + .currentSchemeBanner { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .fieldControl { grid-template-columns: 1fr; } @@ -782,6 +916,8 @@ td { .summaryGrid { grid-template-columns: 1fr; } + + .calculationSteps { grid-template-columns: 1fr 1fr; } } /* ── 浅色主题覆盖 ── */ @@ -928,3 +1064,140 @@ td { color: #334155; border-bottom-color: #e6edf5; } @media (max-width: 560px) { .chartSettingsGrid { grid-template-columns: 1fr; } } + +.authLoading { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.authLoading { color: #64748b; } + +.accountPage h1 { color: #172033; } +.accountPageHeader p { margin-top: 6px; color: #64748b; } + +.accountForm label { + display: grid; + gap: 7px; + color: #334155; + font-size: 14px; + font-weight: 600; +} + +.accountExpiryField { + display: grid; + gap: 7px; + color: #334155; + font-size: 14px; + font-weight: 600; +} + +.accountExpiryInputs, +.tableExpiryEditor { + display: flex; + align-items: center; + gap: 10px; +} + +.accountForm .accountPermanentToggle, +.tableExpiryEditor .accountPermanentToggle { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + color: #475569; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.accountForm .accountPermanentToggle input, +.tableExpiryEditor .accountPermanentToggle input { + width: 15px; + height: 15px; + padding: 0; + accent-color: #6366f1; +} + +.accountExpiryInputs > input[type='date'] { min-width: 0; } +.accountExpiryInputs > input[type='date']:disabled, +.tableExpiryEditor .tableInput:disabled { background: #f1f5f9; color: #94a3b8; } +.tableExpiryEditor .tableInput { min-width: 138px; } + +.accountActions { + display: flex; + align-items: center; + gap: 8px; +} + +.accountEnableButton, +.accountDisableButton { + min-height: 34px; + padding: 0 13px; + border-radius: 7px; + background: #ffffff; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.accountEnableButton { border: 1px solid #86efac; color: #047857; } +.accountEnableButton:hover { background: #ecfdf5; } +.accountDisableButton { border: 1px solid #fecaca; color: #b91c1c; } +.accountDisableButton:hover { background: #fef2f2; } +.accountEnableButton:disabled, +.accountDisableButton:disabled { cursor: not-allowed; opacity: 0.52; } + +.accountForm input { + width: 100%; + height: 40px; + padding: 0 11px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #ffffff; + color: #1e293b; + font: inherit; +} + +.accountForm input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); +} + +.accountPage { + max-width: 1280px; + margin: 0 auto; + padding: 32px 24px 56px; +} + +.accountPageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.accountCreatePanel h2 { + margin-bottom: 16px; + color: #172033; + font-size: 17px; +} + +.accountForm { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + align-items: end; + gap: 14px; +} + +@media (max-width: 820px) { + .accountForm { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 560px) { + .accountForm { grid-template-columns: 1fr; } + .accountPageHeader { align-items: flex-start; flex-direction: column; } +} diff --git a/frontend/web_app/src/App.jsx b/frontend/web_app/src/App.jsx index 6313329..f8f3fd0 100644 --- a/frontend/web_app/src/App.jsx +++ b/frontend/web_app/src/App.jsx @@ -1,15 +1,106 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import AppLayout from './components/AppLayout/AppLayout'; +import AdminAccountsPage from './pages/AdminAccountsPage'; import HomePage from './pages/HomePage'; +import LoginPage from './pages/LoginPage'; +import { getCurrentUser, logout } from './utils/api'; import './App.css'; +function AuthenticatedApp({ user, onLogout }) { + const location = useLocation(); + const activeSection = location.pathname === '/settings' + ? 'settings' + : location.pathname === '/calculation' + ? 'calculation' + : null; + + if (!activeSection && location.pathname !== '/admin/accounts') { + return ; + } + if (location.pathname === '/admin/accounts' && !user.is_admin) { + return ; + } + + return ( + + {activeSection + ? + : } + + ); +} + +function AppContent() { + const navigate = useNavigate(); + const [user, setUser] = useState(undefined); + const [authMessage, setAuthMessage] = useState(''); + + const loadCurrentUser = useCallback(async () => { + try { + setUser(await getCurrentUser()); + } catch (error) { + setUser(null); + if (error.message === '账号异常请联系管理员') { + setAuthMessage(error.message); + } + } + }, []); + + useEffect(() => { + loadCurrentUser(); + }, [loadCurrentUser]); + + useEffect(() => { + const handleAuthRequired = (event) => { + setUser(null); + setAuthMessage(event.detail?.message || '请先登录'); + navigate('/login', { replace: true }); + }; + window.addEventListener('wind-auth-required', handleAuthRequired); + return () => window.removeEventListener('wind-auth-required', handleAuthRequired); + }, [navigate]); + + async function handleLogout() { + try { + await logout(); + } finally { + setUser(null); + setAuthMessage(''); + navigate('/login', { replace: true }); + } + } + + if (user === undefined) { + return
正在检查登录状态…
; + } + if (!user) { + return ( + + { + setUser(currentUser); + setAuthMessage(''); + navigate('/settings', { replace: true }); + }} + /> + )} + /> + } /> + + ); + } + return ; +} + function App() { return ( - - } /> - {/* SPA 回退:未匹配路由统一回到首页 */} - } /> - + ); } diff --git a/frontend/web_app/src/components/AppLayout/AppLayout.jsx b/frontend/web_app/src/components/AppLayout/AppLayout.jsx new file mode 100644 index 0000000..a1d12e3 --- /dev/null +++ b/frontend/web_app/src/components/AppLayout/AppLayout.jsx @@ -0,0 +1,76 @@ +import { NavLink } from 'react-router-dom'; +import styles from './AppLayout.module.css'; + +const NAV_ITEMS = [ + { to: '/settings', icon: '⚙', label: '参数设置' }, + { to: '/calculation', icon: '📈', label: '风电功率计算' }, +]; + +function UserIcon({ admin }) { + if (admin) { + return ( + + ); + } + return ( + + ); +} + +export default function AppLayout({ children, user, onLogout }) { + const navItems = user.is_admin + ? [...NAV_ITEMS, { to: '/admin/accounts', icon: '👤', label: '账号管理' }] + : NAV_ITEMS; + + return ( +
+ + +
+
+
+
+
+ +
+ {user.username} + {user.is_admin && 管理员} +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/frontend/web_app/src/components/AppLayout/AppLayout.module.css b/frontend/web_app/src/components/AppLayout/AppLayout.module.css new file mode 100644 index 0000000..ef35c96 --- /dev/null +++ b/frontend/web_app/src/components/AppLayout/AppLayout.module.css @@ -0,0 +1,96 @@ +.layout { + display: flex; + height: 100vh; + background: #f4f7fb; +} + +.sidebar { + width: 200px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: #ffffff; + border-right: 1px solid #dbe3ed; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px 24px; +} + +.brandLogo { width: 28px; height: 28px; object-fit: contain; } +.brandText { color: #172033; font-size: 14px; font-weight: 700; letter-spacing: 0.2px; white-space: nowrap; } + +.nav { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px; + overflow-y: auto; +} + +.navItem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + color: #64748b; + text-decoration: none; + font-size: 15px; + transition: all 0.15s; +} + +.navItem:hover { color: #334155; background: #f1f5f9; } +.navActive, .navActive:hover { color: #4f46e5; background: #eef2ff; } +.navIcon { width: 20px; color: inherit; font-size: 16px; text-align: center; } +.navLabel { font-weight: 500; white-space: nowrap; } + +.mainContainer { flex: 1; display: flex; flex-direction: column; min-width: 0; } + +.header { + height: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: #ffffff; + border-bottom: 1px solid #dbe3ed; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); +} + +.headerSpacer { flex: 1; } +.userInfo { display: flex; align-items: center; gap: 12px; } +.roleIcon { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; background: #f1f5f9; color: #64748b; } +.adminIcon { color: #4f46e5; background: #eef2ff; } +.userName { color: #1e293b; font-size: 14px; font-weight: 500; } +.roleLabel { color: #4f46e5; font-size: 12px; } +.divider { width: 1px; height: 16px; margin: 0 4px; background: #dbe3ed; } + +.logoutButton { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: #64748b; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.logoutButton:hover { color: #dc2626; background: #fef2f2; } +.content { flex: 1; overflow: auto; min-width: 0; background: #f4f7fb; } + +@media (max-width: 720px) { + .sidebar { width: 64px; } + .brand { justify-content: center; padding-right: 8px; padding-left: 8px; } + .brandText, .navLabel { display: none; } + .navItem { justify-content: center; padding-right: 8px; padding-left: 8px; } +} diff --git a/frontend/web_app/src/pages/AdminAccountsPage.jsx b/frontend/web_app/src/pages/AdminAccountsPage.jsx new file mode 100644 index 0000000..6853866 --- /dev/null +++ b/frontend/web_app/src/pages/AdminAccountsPage.jsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from 'react'; +import { createAccount, getAccounts, updateAccount } from '../utils/api'; + +function formatDateInput(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function defaultExpiry() { + const date = new Date(); + date.setDate(date.getDate() + 30); + return formatDateInput(date); +} + +function defaultAccountForm() { + return { username: '', password: '', expires_on: defaultExpiry(), permanent: false }; +} + +export default function AdminAccountsPage() { + const [accounts, setAccounts] = useState([]); + const [form, setForm] = useState(defaultAccountForm); + const [drafts, setDrafts] = useState({}); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function loadAccounts() { + const data = await getAccounts(); + setAccounts(data.accounts || []); + setDrafts(Object.fromEntries((data.accounts || []).map((account) => [account.username, { + expires_on: account.expires_on, + permanent: account.permanent ?? !account.expires_on, + password: '', + }]))); + } + + useEffect(() => { + loadAccounts().catch((requestError) => setError(requestError.message || '加载账号失败')); + }, []); + + async function handleCreate(event) { + event.preventDefault(); + setSubmitting(true); + setError(''); + setMessage(''); + try { + await createAccount({ + username: form.username.trim(), + password: form.password, + expires_on: form.permanent ? '' : form.expires_on, + }); + setMessage(`账号 ${form.username.trim()} 已创建`); + setForm(defaultAccountForm()); + await loadAccounts(); + } catch (requestError) { + setError(requestError.message || '创建账号失败'); + } finally { + setSubmitting(false); + } + } + + async function handleUpdate(username) { + const draft = drafts[username]; + setSubmitting(true); + setError(''); + setMessage(''); + try { + await updateAccount(username, { + expires_on: draft.permanent ? '' : draft.expires_on, + ...(draft.password ? { password: draft.password } : {}), + }); + setMessage(`账号 ${username} 已更新,原会话已失效`); + await loadAccounts(); + } catch (requestError) { + setError(requestError.message || '更新账号失败'); + } finally { + setSubmitting(false); + } + } + + async function handleToggleEnabled(account) { + setSubmitting(true); + setError(''); + setMessage(''); + try { + await updateAccount(account.username, { + expires_on: account.expires_on, + enabled: !account.enabled, + }); + setMessage(`账号 ${account.username} 已${account.enabled ? '停用' : '启用'}`); + await loadAccounts(); + } catch (requestError) { + setError(requestError.message || '更新账号状态失败'); + } finally { + setSubmitting(false); + } + } + + function updateDraft(username, field, value) { + setDrafts((current) => ({ + ...current, + [username]: { ...current[username], [field]: value }, + })); + } + + return ( +
+
+

账号管理

创建账号并设置登录密码和有效期

+
+ + {error &&
{error}
} + {message &&
{message}
} + +
+

新增账号

+
+ + +
+ 有效期 +
+ setForm({ ...form, expires_on: event.target.value })} + /> + +
+
+ +
+
+ +
+

已有账号

{accounts.length} 个账号
+
+ + + + {accounts.map((account) => ( + + + + + + + + + ))} + +
账号角色状态有效期新密码操作
{account.username}{account.is_admin ? '管理员' : '普通账号'} + + {!account.enabled ? '已停用' : account.expired ? '已过期' : '正常'} + + + {account.is_admin ? '永久' : ( +
+ updateDraft( + account.username, + 'expires_on', + event.target.value, + )} + /> + +
+ )} +
+ {account.is_admin ? '—' : ( + updateDraft( + account.username, + 'password', + event.target.value, + )} + /> + )} + + {!account.is_admin && ( +
+ + +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index aff0810..bfa2a0a 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -6,6 +6,7 @@ import { deleteWindJob, downloadWindReport, finishWindJob, + getWindFanPoints, getWindChartOptions, getWindSchemes, saveWindChartOptions, @@ -17,33 +18,18 @@ import { alignDesignCurveToActualCurve, buildPowerCurveModel, getDrawableActualCurve, + hasDrawablePowerCurveData, } from '../utils/powerCurveModel'; +import { resolvePowerCurveLegendLayout } from '../utils/powerCurveLegendLayout'; +import { + BASE_REQUIRED_FIELDS, + inferWindMapping, + normalizeHeader, + PITCH_REQUIRED_FIELDS, + ROTOR_SPEED_FIELD, +} from '../utils/windFieldMapping'; -const REQUIRED_FIELDS = [ - { key: 'time', label: '时间' }, - { key: 'fan_id', label: '风机编号' }, - { key: 'wind_speed', label: '风速' }, - { key: 'active_power', label: '有功功率' }, - { key: 'generator_speed', label: '发电机转速' }, - { key: 'blade_pitch_1', label: '1#叶片角度' }, - { key: 'blade_pitch_2', label: '2#叶片角度' }, - { key: 'blade_pitch_3', label: '3#叶片角度' }, -]; - -const FIELD_HINTS = { - time: ['时间', 'time', 'timestamp', '日期'], - fan_id: ['风机编号', '风机', '机组编号', 'fan', 'turbine'], - wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'], - active_power: ['平均有功功率', '有功功率', 'active power', 'power'], - generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'], - blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'], - blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'], - blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'], -}; - -const FIELD_EXCLUDES = { - active_power: ['限功率', '限电', '时间', '累计'], -}; +const CALCULATION_STEPS = ['准备数据', '上传数据', '清洗计算', '计算完成']; const DESIGN_FIELDS = [ { key: 'wind_speed', label: '风速' }, @@ -55,13 +41,14 @@ const DESIGN_FIELD_HINTS = { design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'], }; -const CHUNK_SIZE = 4000; +const MAX_UPLOAD_CHUNK_BYTES = 6 * 1024 * 1024; const CHART_HEIGHT = 580; const BRUSH_RADIUS = 10; const EDIT_TOOL_RESTORE = 'restore'; const EDIT_TOOL_ERASE = 'erase'; const DEFAULT_SCHEME_ID = 'scheme_one'; const SCHEME_TWO_ID = 'scheme_two'; +const SCHEME_THREE_ID = 'scheme_three'; const DEFAULT_SCHEMES = [ { id: 'scheme_one', @@ -93,6 +80,24 @@ const DEFAULT_SCHEMES = [ report_wind_speed_interval: 0.25, }, }, + { + id: SCHEME_THREE_ID, + name: '方案三', + description: '叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比', + parameters: { + rated_power: 4800, + rated_wind_speed: 14, + power_step: 5, + cleaning_wind_speed_step: 0.25, + wind_speed_change_threshold: 1, + iqr_lower_multiplier: 1.2, + iqr_upper_multiplier: 2, + minimum_generator_speed: 1, + generator_speed_k: 0.9, + rotor_radius: 78, + report_wind_speed_interval: 0.25, + }, + }, ]; const DEFAULT_SCHEME_TWO_PARAMS = { @@ -115,6 +120,19 @@ const DEFAULT_SCHEME_ONE_PARAMS = { gearbox_ratio: '162', report_wind_speed_interval: '0.25', }; +const DEFAULT_SCHEME_THREE_PARAMS = { + rated_power: '4800', + rated_wind_speed: '14', + power_step: '5', + cleaning_wind_speed_step: '0.25', + wind_speed_change_threshold: '1', + iqr_lower_multiplier: '1.2', + iqr_upper_multiplier: '2', + minimum_generator_speed: '1', + generator_speed_k: '0.9', + rotor_radius: '78', + report_wind_speed_interval: '0.25', +}; const SCHEME_ONE_PARAM_FIELDS = [ { key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' }, { key: 'rated_wind_speed', label: '额定风速', unit: 'm/s', min: '0', step: '0.01' }, @@ -129,6 +147,9 @@ const SCHEME_ONE_PARAM_FIELDS = [ { key: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' }, { key: 'report_wind_speed_interval', label: '报告公式风速区间半宽', unit: 'm/s', min: '0', step: '0.01' }, ]; +const SCHEME_THREE_PARAM_FIELDS = SCHEME_ONE_PARAM_FIELDS.filter( + (field) => field.key !== 'gearbox_ratio', +); const CHART_STATE_KEY = 'wind_power_chart_state_v1'; const DEFAULT_CHART_OPTIONS = { title: '#机组功率曲线', @@ -206,50 +227,6 @@ function createDesignRows(points) { })); } -function normalizeHeader(value) { - return String(value ?? '') - .trim() - .toLowerCase() - .replace(/\s+/g, '') - .replace(/[()()_\-./]/g, ''); -} - -function inferMapping(headers) { - const normalized = headers.map((header) => ({ - header, - normalized: normalizeHeader(header), - })); - const mapping = {}; - - for (const field of REQUIRED_FIELDS) { - const hints = FIELD_HINTS[field.key].map(normalizeHeader); - const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader); - let best = null; - for (const item of normalized) { - if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) { - continue; - } - let score = 0; - hints.forEach((hint, index) => { - const weight = hints.length - index; - if (item.normalized === hint) { - score = Math.max(score, 100 + weight); - } else if (item.normalized.includes(hint)) { - score = Math.max(score, 60 + weight); - } else if (hint.includes(item.normalized)) { - score = Math.max(score, 20 + weight); - } - }); - if (!best || score > best.score) { - best = { ...item, score }; - } - } - mapping[field.key] = best && best.score > 0 ? best.header : ''; - } - - return mapping; -} - function inferDesignMapping(headers) { const normalized = headers.map((header) => ({ header, @@ -285,57 +262,10 @@ function inferDesignMapping(headers) { return mapping; } -function excelSerialToDate(value) { - const days = Number(value); - if (!Number.isFinite(days) || days <= 0) { - return null; - } - const utcDays = Math.floor(days - 25569); - const utcValue = utcDays * 86400; - const dateInfo = new Date(utcValue * 1000); - const fractionalDay = days - Math.floor(days) + 0.0000001; - const totalSeconds = Math.floor(86400 * fractionalDay); - dateInfo.setSeconds(totalSeconds); - return dateInfo; -} - function pad(value) { return String(value).padStart(2, '0'); } -function formatDate(date) { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` - + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; -} - -function normalizeTime(value) { - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return formatDate(value); - } - if (typeof value === 'number') { - const date = excelSerialToDate(value); - return date ? formatDate(date) : ''; - } - - const text = String(value ?? '').trim(); - if (!text) return ''; - - const isoMatch = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/); - if (isoMatch) { - const [, year, month, day, hour, minute, second = '0'] = isoMatch; - return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`; - } - - const slashMatch = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/); - if (slashMatch) { - const [, month, day, rawYear, hour, minute, second = '0'] = slashMatch; - const year = rawYear.length === 2 ? `20${rawYear}` : rawYear; - return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`; - } - - return text; -} - function normalizeNumber(value) { if (typeof value === 'number') { return Number.isFinite(value) ? value : null; @@ -439,38 +369,35 @@ function getCell(row, headerIndex) { return row[headerIndex]; } -function buildStandardRows(files, mapping) { - const firstHeaders = files[0]?.headers || []; - const indexes = Object.fromEntries( - REQUIRED_FIELDS.map((field) => [field.key, firstHeaders.indexOf(mapping[field.key])]), - ); - - const rows = []; +function buildRawRows(files) { const rawRows = []; for (const file of files) { - const localIndexes = Object.fromEntries( - REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]), - ); for (let rowIndex = 0; rowIndex < file.rows.length; rowIndex += 1) { - const row = file.rows[rowIndex]; - rows.push({ - time: normalizeTime(getCell(row, localIndexes.time)), - fan_id: String(getCell(row, localIndexes.fan_id) ?? '').trim(), - wind_speed: normalizeNumber(getCell(row, localIndexes.wind_speed)), - active_power: normalizeNumber(getCell(row, localIndexes.active_power)), - generator_speed: normalizeNumber(getCell(row, localIndexes.generator_speed)), - blade_pitch_1: normalizeNumber(getCell(row, localIndexes.blade_pitch_1)), - blade_pitch_2: normalizeNumber(getCell(row, localIndexes.blade_pitch_2)), - blade_pitch_3: normalizeNumber(getCell(row, localIndexes.blade_pitch_3)), - }); rawRows.push({ file_name: file.file_name, - values: [...(file.raw_rows?.[rowIndex] || row)], + values: [...(file.raw_rows?.[rowIndex] || file.rows[rowIndex])], }); } } + return rawRows; +} - return { rows, rawRows, indexes }; +function splitRawRowsIntoChunks(rawRows) { + const chunks = []; + let chunk = []; + let size = 0; + for (const row of rawRows) { + const rowSize = JSON.stringify(row).length; + if (chunk.length && size + rowSize > MAX_UPLOAD_CHUNK_BYTES) { + chunks.push(chunk); + chunk = []; + size = 0; + } + chunk.push(row); + size += rowSize; + } + if (chunk.length) chunks.push(chunk); + return chunks; } function hasMatchingHeaderSequence(files) { @@ -491,12 +418,13 @@ function sanitizeFileName(value) { return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_'); } -function exportValidRowsToExcel(fanId, rows) { +function exportValidRowsToExcel(fanId, rows, includeRotorSpeed = false) { const exportRows = (rows || []).map((row) => ({ 风机编号: row.fan_id || fanId || '', 采样时间: row.time || '', 平均功率: row.active_power ?? '', 平均转速: row.generator_speed ?? '', + ...(includeRotorSpeed ? { 叶轮转速: row.rotor_speed ?? '' } : {}), 平均风速: row.wind_speed ?? '', '3个叶片变桨角平均值': row.pitch_angle_average ?? '', })); @@ -603,6 +531,10 @@ function schemeParamsToState(parameters) { parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed, ), rated_power: String(parameters?.rated_power ?? DEFAULT_SCHEME_TWO_PARAMS.rated_power), + report_wind_speed_interval: String( + parameters?.report_wind_speed_interval + ?? DEFAULT_SCHEME_TWO_PARAMS.report_wind_speed_interval, + ), }; } @@ -613,6 +545,37 @@ function schemeOneParamsToState(parameters) { ])); } +function schemeThreeParamsToState(parameters) { + return Object.fromEntries(Object.entries(DEFAULT_SCHEME_THREE_PARAMS).map(([field, fallback]) => [ + field, + String(parameters?.[field] ?? fallback), + ])); +} + +function normalizeParams(params) { + return Object.fromEntries(Object.entries(params).map(([field, value]) => [ + field, + normalizeNumber(value), + ])); +} + +function areBaseSchemeParamsInvalid(values, requireGearboxRatio) { + return !Number.isFinite(values.rated_power) || values.rated_power <= 0 || + !Number.isFinite(values.rated_wind_speed) || values.rated_wind_speed <= 0 || + !Number.isFinite(values.power_step) || values.power_step <= 0 || + !Number.isFinite(values.cleaning_wind_speed_step) || + values.cleaning_wind_speed_step <= 0 || + !Number.isFinite(values.rotor_radius) || values.rotor_radius <= 0 || + (requireGearboxRatio && + (!Number.isFinite(values.gearbox_ratio) || values.gearbox_ratio <= 0)) || + !Number.isFinite(values.report_wind_speed_interval) || + values.report_wind_speed_interval <= 0 || values.report_wind_speed_interval > 2 || + ['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier', + 'minimum_generator_speed', 'generator_speed_k'].some((field) => ( + !Number.isFinite(values[field]) || values[field] < 0 + )); +} + function SummaryCards({ summary }) { if (!summary) return null; const cards = [ @@ -930,28 +893,44 @@ function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered ]; context.textAlign = 'left'; context.font = `${typography.legend}px "Segoe UI", sans-serif`; - const legendLeft = padding.left + 22; - const legendTop = padding.top + Math.max(30, typography.legend + 10); - const legendLineHeight = Math.max(34, typography.legend + 12); + const legendLayout = resolvePowerCurveLegendLayout({ + plotLeft: padding.left, + plotTop: padding.top, + plotWidth, + plotHeight, + fontSize: typography.legend, + entries, + textWidths: entries.map(([label]) => context.measureText(label).width), + }); + context.save(); + context.beginPath(); + context.rect(padding.left, padding.top, plotWidth, plotHeight); + context.clip(); entries.forEach(([label, type], index) => { - const top = legendTop + index * legendLineHeight; + const top = legendLayout.top + index * legendLayout.lineHeight; + const markerY = top + legendLayout.textHeight / 2; + const textBaseline = top + typography.legend; const isScatter = type === 'scatter'; const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null; if (isScatter) { - drawMarker(context, legendLeft + 10, top, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity)); + drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity)); } else if (style) { context.save(); context.strokeStyle = style.color; context.lineWidth = 3; context.setLineDash(getLineDash(style.lineStyle)); - context.beginPath(); context.moveTo(legendLeft, top); context.lineTo(legendLeft + 20, top); context.stroke(); + context.beginPath(); + context.moveTo(legendLayout.left, markerY); + context.lineTo(legendLayout.left + legendLayout.markerWidth, markerY); + context.stroke(); context.restore(); } else { - drawMarker(context, legendLeft + 10, top, 'circle', 4, type); + drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, type); } context.fillStyle = textColor; - context.fillText(label, legendLeft + 30, top + 7); + context.fillText(label, legendLayout.left + legendLayout.labelOffset, textBaseline); }); + context.restore(); } return canvas.toDataURL('image/png'); } @@ -1288,7 +1267,14 @@ function PowerCurveChart({ chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current()); useEffect(() => { - if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined; + const hasDrawableData = hasDrawablePowerCurveData({ + actualCurve: visibleActualCurve, + designCurve: visibleDesignCurve, + scatterPoints: validScatter, + filteredPoints: validFiltered, + showFiltered, + }); + if (!chartRef.current || !hasDrawableData) return undefined; const transientErasedPointIds = new Set(); let redrawFrameId = 0; @@ -1628,7 +1614,13 @@ function PowerCurveChart({ } }, [editMode]); - if (!actualCurve.length && !validDesign.length) { + if (!hasDrawablePowerCurveData({ + actualCurve, + designCurve: validDesign, + scatterPoints: validScatter, + filteredPoints: validFiltered, + showFiltered, + })) { return
暂无可绘制的曲线数据
; } @@ -1673,13 +1665,17 @@ function PowerCurveChart({ ); } -export default function HomePage() { +export default function HomePage({ activeSection = 'settings' }) { const savedSessionState = useMemo(() => loadChartState(), []); const [files, setFiles] = useState([]); const [mapping, setMapping] = useState({}); const [reading, setReading] = useState(false); const [submitting, setSubmitting] = useState(false); const [progress, setProgress] = useState(''); + const [progressPercent, setProgressPercent] = useState(0); + const [progressStage, setProgressStage] = useState(-1); + const [performanceTimings, setPerformanceTimings] = useState(null); + const [loadingFanPoints, setLoadingFanPoints] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(() => savedSessionState.result || null); const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || ''); @@ -1724,11 +1720,15 @@ export default function HomePage() { const [schemeMessage, setSchemeMessage] = useState(''); const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS); const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS); + const [schemeThreeParams, setSchemeThreeParams] = useState(DEFAULT_SCHEME_THREE_PARAMS); const headers = files[0]?.headers || []; const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0]; const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID; - const mappingFields = isSchemeTwo ? REQUIRED_FIELDS : REQUIRED_FIELDS.slice(0, 5); + const isSchemeThree = selectedSchemeId === SCHEME_THREE_ID; + const mappingFields = isSchemeTwo + ? [...BASE_REQUIRED_FIELDS, ...PITCH_REQUIRED_FIELDS] + : (isSchemeThree ? [...BASE_REQUIRED_FIELDS, ROTOR_SPEED_FIELD] : BASE_REQUIRED_FIELDS); const missingFields = mappingFields.filter((field) => !mapping[field.key]); const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : []; const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : []; @@ -1797,6 +1797,28 @@ export default function HomePage() { ? result.estimated_params[selectedFan] : null; + useEffect(() => { + if (!result?.job_id || !selectedFan || result?.scatter_points?.[selectedFan]) return; + let cancelled = false; + setLoadingFanPoints(true); + getWindFanPoints(result.job_id, selectedFan) + .then((points) => { + if (cancelled) return; + setResult((current) => ({ + ...current, + scatter_points: { ...(current.scatter_points || {}), [selectedFan]: points.scatter_points || [] }, + filtered_points: { ...(current.filtered_points || {}), [selectedFan]: points.filtered_points || [] }, + })); + }) + .catch((err) => { + if (!cancelled) setError(err.message || '加载风机点数据失败'); + }) + .finally(() => { + if (!cancelled) setLoadingFanPoints(false); + }); + return () => { cancelled = true; }; + }, [result?.job_id, result?.scatter_points, selectedFan]); + useEffect(() => { try { sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({ @@ -2030,7 +2052,7 @@ export default function HomePage() { return; } setExportingReport(true); - setReportExportProgress('正在服务器生成完整报告'); + setReportExportProgress('正在生成报告图表'); setError(''); try { const chartRenderSnapshot = await chartSnapshotRef.current?.(false); @@ -2038,13 +2060,19 @@ export default function HomePage() { throw new Error('图表尚未完成绘制,请稍后重试'); } const chartImageData = renderPowerCurvePng(chartRenderSnapshot); - const blob = await downloadWindReport(result.job_id, { + setReportExportProgress('正在上传图表和编辑结果'); + const report = await downloadWindReport(result.job_id, { fan_id: selectedFan, - effective_rows: effectiveScatter, report_rows: reportCurveRows, + restored_point_ids: restoredPointIds[selectedFan] || [], + erased_point_ids: erasedPointIds[selectedFan] || [], chart_image: chartImageData || '', }); - downloadReportBlob(blob, selectedFan); + setReportExportProgress('正在下载完整报告'); + downloadReportBlob(report.blob, selectedFan); + if (report.serverTiming) { + setReportExportNotice(`报告已生成(${report.serverTiming})`); + } setExportingReport(false); setReportExportProgress(''); } catch (err) { @@ -2056,12 +2084,13 @@ export default function HomePage() { } }, [ canExportFullReport, - effectiveScatter, exportingReport, reportExportDisabledReason, reportCurveRows, selectedFan, result?.job_id, + restoredPointIds, + erasedPointIds, ]); const handleCancelReportExport = useCallback(() => { @@ -2090,6 +2119,8 @@ export default function HomePage() { setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters)); const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID); setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters)); + const schemeThree = loadedSchemes.find((scheme) => scheme.id === SCHEME_THREE_ID); + setSchemeThreeParams(schemeThreeParamsToState(schemeThree?.parameters)); } catch (err) { if (!canceled) { setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案'); @@ -2111,6 +2142,8 @@ export default function HomePage() { setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters)); } else if (value === SCHEME_TWO_ID) { setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters)); + } else if (value === SCHEME_THREE_ID) { + setSchemeThreeParams(schemeThreeParamsToState(nextScheme?.parameters)); } setSchemeMessage(''); } @@ -2129,6 +2162,13 @@ export default function HomePage() { })); } + function handleSchemeThreeParamChange(field, value) { + setSchemeThreeParams((prev) => ({ + ...prev, + [field]: value, + })); + } + async function handleSaveSchemeDescription() { if (!selectedScheme) return; @@ -2136,30 +2176,20 @@ export default function HomePage() { setError(''); setSchemeMessage(''); try { - const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [ - field, - normalizeNumber(value), - ])); + const schemeOneValues = normalizeParams(schemeOneParams); + const schemeThreeValues = normalizeParams(schemeThreeParams); const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); const ratedPower = normalizeNumber(schemeTwoParams.rated_power); const reportWindSpeedInterval = normalizeNumber(schemeTwoParams.report_wind_speed_interval); if (selectedScheme.id === DEFAULT_SCHEME_ID && - (!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 || - !Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 || - !Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 || - !Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || schemeOneValues.cleaning_wind_speed_step <= 0 || - !Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 || - !Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 || - !Number.isFinite(schemeOneValues.report_wind_speed_interval) || - schemeOneValues.report_wind_speed_interval <= 0 || - schemeOneValues.report_wind_speed_interval > 2 || - ['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier', - 'minimum_generator_speed', 'generator_speed_k'].some((field) => ( - !Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0 - )))) { + areBaseSchemeParamsInvalid(schemeOneValues, true)) { throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0'); } + if (selectedScheme.id === SCHEME_THREE_ID && + areBaseSchemeParamsInvalid(schemeThreeValues, false)) { + throw new Error('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0'); + } if (selectedScheme.id === SCHEME_TWO_ID && (!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 || !Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 || @@ -2171,6 +2201,7 @@ export default function HomePage() { const data = await saveWindSchemeDescription(selectedScheme.id, { description: schemeDescription, ...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}), + ...(selectedScheme.id === SCHEME_THREE_ID ? { parameters: schemeThreeValues } : {}), ...(selectedScheme.id === SCHEME_TWO_ID ? { parameters: { grid_connected_speed: gridConnectedSpeed, @@ -2189,6 +2220,8 @@ export default function HomePage() { setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters)); } else if (savedScheme.id === SCHEME_TWO_ID) { setSchemeTwoParams(schemeParamsToState(savedScheme.parameters)); + } else if (savedScheme.id === SCHEME_THREE_ID) { + setSchemeThreeParams(schemeThreeParamsToState(savedScheme.parameters)); } setSchemeMessage('已保存'); } catch (err) { @@ -2205,6 +2238,10 @@ export default function HomePage() { setReading(true); setError(''); setResult(null); + setProgress(''); + setProgressPercent(0); + setProgressStage(-1); + setPerformanceTimings(null); setSelectedFan(''); setShowFiltered(false); setIsEditMode(false); @@ -2221,7 +2258,7 @@ export default function HomePage() { throw new Error('同一批上传文件的列头和列顺序必须完全一致'); } setFiles(parsedFiles); - setMapping(inferMapping(parsedFiles[0].headers)); + setMapping(inferWindMapping(parsedFiles[0].headers)); } catch (err) { setError(err.message || '读取 Excel 失败'); } finally { @@ -2285,29 +2322,17 @@ export default function HomePage() { setError('请先上传文件并完成所有字段映射'); return; } - const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [ - field, - normalizeNumber(value), - ])); - const schemeOneInvalid = - !Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 || - !Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 || - !Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 || - !Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || - schemeOneValues.cleaning_wind_speed_step <= 0 || - !Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 || - !Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 || - !Number.isFinite(schemeOneValues.report_wind_speed_interval) || - schemeOneValues.report_wind_speed_interval <= 0 || - schemeOneValues.report_wind_speed_interval > 2 || - ['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier', - 'minimum_generator_speed', 'generator_speed_k'].some((field) => ( - !Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0 - )); - if (!isSchemeTwo && schemeOneInvalid) { + const schemeOneValues = normalizeParams(schemeOneParams); + const schemeThreeValues = normalizeParams(schemeThreeParams); + if (!isSchemeTwo && !isSchemeThree && + areBaseSchemeParamsInvalid(schemeOneValues, true)) { setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0'); return; } + if (isSchemeThree && areBaseSchemeParamsInvalid(schemeThreeValues, false)) { + setError('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0'); + return; + } const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed); const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed); const ratedPower = normalizeNumber(schemeTwoParams.rated_power); @@ -2332,10 +2357,17 @@ export default function HomePage() { setRestoredPointIds({}); setErasedPointIds({}); setConfirmedCurveScatterByFan({}); + setPerformanceTimings(null); setProgress('正在准备标准化数据'); + setProgressPercent(5); + setProgressStage(0); try { - const { rows, rawRows } = buildStandardRows(files, mapping); + const totalStartedAt = performance.now(); + const rawRows = buildRawRows(files); + const chunks = splitRawRowsIntoChunks(rawRows); + setProgress('正在创建计算任务'); + setProgressPercent(10); const start = await startWindJob({ files: files.map((file) => ({ file_name: file.file_name, @@ -2346,25 +2378,29 @@ export default function HomePage() { }); jobId = start.job_id; - const chunkCount = Math.ceil(rows.length / CHUNK_SIZE); - for (let index = 0; index < chunkCount; index += 1) { - const chunkRows = rows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE); - setProgress(`正在上传数据分片 ${index + 1}/${chunkCount}`); + const uploadStartedAt = performance.now(); + setProgressStage(1); + for (let index = 0; index < chunks.length; index += 1) { + setProgress(`正在上传原始数据分片 ${index + 1}/${chunks.length}`); + setProgressPercent(15 + Math.round((index / chunks.length) * 55)); await uploadWindChunk({ job_id: jobId, chunk_index: index, - rows: chunkRows, - raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE), + raw_rows: chunks[index], }); + setProgressPercent(15 + Math.round(((index + 1) / chunks.length) * 55)); } setProgress('正在清洗数据并计算功率曲线'); + setProgressPercent(75); + setProgressStage(2); const calculation = await finishWindJob({ job_id: jobId, options: { curve_wind_speed_step: 0.5, scheme_id: selectedSchemeId, - ...(!isSchemeTwo ? schemeOneValues : {}), + ...(!isSchemeTwo && !isSchemeThree ? schemeOneValues : {}), + ...(isSchemeThree ? schemeThreeValues : {}), ...(isSchemeTwo ? { grid_connected_speed: gridConnectedSpeed, rated_generator_speed: ratedGeneratorSpeed, @@ -2373,9 +2409,17 @@ export default function HomePage() { } : {}), }, }); + const initialFan = calculation.initial_fan_id || calculation.fans?.[0] || ''; setResult({ ...calculation, job_id: jobId }); - setSelectedFan(calculation.fans?.[0] || ''); + setSelectedFan(initialFan); + setPerformanceTimings({ + ...(calculation.timings_ms || {}), + upload: Math.round(performance.now() - uploadStartedAt), + total: Math.round(performance.now() - totalStartedAt), + }); setProgress('计算完成'); + setProgressPercent(100); + setProgressStage(3); } catch (err) { if (jobId) { try { @@ -2390,6 +2434,8 @@ export default function HomePage() { setReportExportNotice(message); } setProgress(''); + setProgressPercent(0); + setProgressStage(-1); } finally { setSubmitting(false); } @@ -2399,10 +2445,14 @@ export default function HomePage() {
-

风电功率计算平台

-

多 Excel 导入、字段映射、数据清洗与功率曲线计算

+

{activeSection === 'settings' ? '参数设置' : '风电功率计算'}

+

+ {activeSection === 'settings' + ? '配置计算方案与设计功率曲线' + : '多 Excel 导入、字段映射、数据清洗与功率曲线计算'} +

-
+ {activeSection === 'calculation' &&
-
+
}
{error &&
{error}
} - {progress &&
{progress}
} + {activeSection === 'calculation' && ( +
+
+ 当前使用方案 + {selectedScheme?.name || '方案一'} +
+

{schemeDescription || selectedScheme?.description || '暂无方案描述'}

+
+ )} {reportExportNotice && (
@@ -2432,7 +2490,7 @@ export default function HomePage() {
)} -
+

计算方案

@@ -2469,18 +2527,24 @@ export default function HomePage() { {!isSchemeTwo && ( <>

- 叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速 + {isSchemeThree + ? '叶尖速比 = 叶轮转速 × 3.14 × 叶轮半径 × 30 ÷ 风速' + : '叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速'}

- {SCHEME_ONE_PARAM_FIELDS.map((field) => ( + {(isSchemeThree ? SCHEME_THREE_PARAM_FIELDS : SCHEME_ONE_PARAM_FIELDS).map((field) => (
-
+

设计功率曲线

@@ -2650,7 +2714,7 @@ export default function HomePage() {
-
+

1. 数据文件

@@ -2699,6 +2763,36 @@ export default function HomePage() { ))}
)} + {progress && ( +
+
+ 计算进度 + {progressPercent}% +
+
+ {CALCULATION_STEPS.map((step, index) => ( +
+ {index < progressStage || progressStage === 3 ? '✓' : index + 1} + {step} +
+ ))} +
+ +

{progress}

+
+ )} + {performanceTimings && ( +
+ 上传 {performanceTimings.upload ?? 0} ms · 服务端计算 {performanceTimings.calculation ?? 0} ms + (读取 {performanceTimings.read_parse ?? 0} ms,清洗 {performanceTimings.cleaning_and_points ?? 0} ms) + · 总计 {performanceTimings.total ?? 0} ms +
+ )}
-
+

3. 列头预览

{headers.length ? `${headers.length} 列` : '暂无'} @@ -2729,7 +2823,7 @@ export default function HomePage() {
{result && ( -
+

4. 清洗结果

@@ -2758,6 +2852,7 @@ export default function HomePage() { 曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点 · 滤除 {visibleFilteredPoints.length.toLocaleString()} 点 + {loadingFanPoints ? ' · 正在加载点数据…' : ''} +
+
+ + {error &&
{error}
} + + + +
+ + Wind Power Analysis + +
+ +
+ ); +} diff --git a/frontend/web_app/src/pages/LoginPage.module.css b/frontend/web_app/src/pages/LoginPage.module.css new file mode 100644 index 0000000..989cf8b --- /dev/null +++ b/frontend/web_app/src/pages/LoginPage.module.css @@ -0,0 +1,303 @@ +/* ─── 页面容器 ─── */ +.page { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background: #080812; + overflow: hidden; +} + +/* ─── 星空穿梭 + 闪烁星星 Canvas ─── */ +.starfield { + position: absolute; + inset: 0; + z-index: 0; +} + +/* ─── 中心径向辉光 ─── */ +.radialGlow { + position: absolute; + inset: 0; + z-index: 1; + background: + radial-gradient(ellipse 60% 50% at 50% 50%, + rgba(99, 102, 241, 0.08) 0%, + transparent 70%), + radial-gradient(ellipse 40% 30% at 50% 45%, + rgba(139, 92, 246, 0.06) 0%, + transparent 60%); + pointer-events: none; +} + +/* ─── 浮动粒子 ─── */ +.particlesLayer { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + overflow: hidden; +} + +.particle { + position: absolute; + bottom: -10px; + border-radius: 50%; + background: #818cf8; + filter: blur(1px); + animation: floatUp linear infinite; +} + +@keyframes floatUp { + 0% { + transform: translateY(0) translateX(0); + opacity: 0; + } + 10% { + opacity: var(--opacity, 0.3); + } + 90% { + opacity: var(--opacity, 0.3); + } + 100% { + transform: translateY(-100vh) translateX(30px); + opacity: 0; + } +} + +/* ─── 登录卡片 ─── */ +.card { + position: relative; + z-index: 10; + width: 400px; + background: rgba(18, 18, 30, 0.75); + border: 1px solid rgba(99, 102, 241, 0.15); + border-radius: 16px; + padding: 44px 36px 32px; + backdrop-filter: blur(20px) saturate(1.4); + box-shadow: + 0 0 60px rgba(99, 102, 241, 0.08), + 0 0 120px rgba(139, 92, 246, 0.04), + 0 24px 48px rgba(0, 0, 0, 0.5); + animation: cardEnter 0.8s ease-out; +} + +/* 卡片顶部光带 */ +.cardGlow { + position: absolute; + top: -1px; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, + transparent, + rgba(99, 102, 241, 0.6), + rgba(168, 85, 247, 0.6), + transparent); + border-radius: 2px; + animation: glowPulse 3s ease-in-out infinite; +} + +@keyframes cardEnter { + from { + opacity: 0; + transform: translateY(30px) scale(0.96); + filter: blur(8px); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +@keyframes glowPulse { + 0%, 100% { opacity: 0.5; left: 20%; right: 20%; } + 50% { opacity: 1; left: 10%; right: 10%; } +} + +/* ─── Logo ─── */ +.logo { + display: block; + width: 60px; + height: 60px; + margin: 0 auto 18px; + object-fit: contain; + filter: drop-shadow(0 0 16px rgba(99, 102, 241, 0.3)); + animation: logoPulse 4s ease-in-out infinite; +} + +@keyframes logoPulse { + 0%, 100% { filter: drop-shadow(0 0 16px rgba(99, 102, 241, 0.3)); } + 50% { filter: drop-shadow(0 0 24px rgba(139, 92, 246, 0.5)); } +} + +/* ─── 标题 ─── */ +.title { + font-size: 28px; + font-weight: 700; + text-align: center; + margin: 0 0 6px; + background: linear-gradient(135deg, #e0e7ff, #c7d2fe, #a5b4fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 2px; +} + +.subtitle { + font-size: 16px; + color: #6b7280; + text-align: center; + margin: 16px 0 22px; + letter-spacing: 6px; +} + +/* ─── 表单字段 ─── */ +.field { + margin-bottom: 22px; +} + +.label { + display: block; + font-size: 15px; + color: #8b8fa3; + margin-bottom: 8px; + letter-spacing: 0.5px; +} + +.input { + width: 100%; + padding: 11px 14px; + background: rgba(20, 20, 36, 0.8); + border: 1px solid rgba(99, 102, 241, 0.12); + border-radius: 8px; + color: #d1d5db; + font-size: 17px; + outline: none; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.input:focus { + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08); + background: rgba(20, 20, 36, 1); +} + +.input::placeholder { + color: #4a4e5a; +} + +.inputWrap { + position: relative; +} + +.inputWrap .input { + padding-right: 42px; +} + +.eyeBtn { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: #4a4e5a; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} + +.eyeBtn:hover { + color: #818cf8; +} + +/* ─── 错误提示 ─── */ +.error { + font-size: 13px; + color: #f87171; + margin-bottom: 16px; + text-align: center; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.06); + border: 1px solid rgba(239, 68, 68, 0.15); + border-radius: 6px; +} + +/* ─── 登录按钮 ─── */ +.button { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + border: none; + border-radius: 8px; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + letter-spacing: 4px; + transition: all 0.2s ease; + box-shadow: 0 4px 16px rgba(99, 102, 241, 0.25); + position: relative; + overflow: hidden; +} + +.button::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, + transparent, + rgba(255, 255, 255, 0.08), + transparent); + transform: translateX(-100%); + transition: transform 0.5s ease; +} + +.button:hover { + box-shadow: 0 4px 24px rgba(99, 102, 241, 0.4); + transform: translateY(-1px); +} + +.button:hover::before { + transform: translateX(100%); +} + +.button:active { + transform: translateY(0); +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* ─── 底部 ─── */ +.footer { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 28px; +} + +.footerDot { + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(99, 102, 241, 0.3); +} + +.footerText { + font-size: 13px; + color: #4a4e5a; + letter-spacing: 1px; +} diff --git a/frontend/web_app/src/utils/api.js b/frontend/web_app/src/utils/api.js index c1d94bf..45791b2 100644 --- a/frontend/web_app/src/utils/api.js +++ b/frontend/web_app/src/utils/api.js @@ -20,6 +20,7 @@ async function request(url, options = {}) { const resp = await fetch(`${BASE_URL}${url}`, { cache: 'no-store', + credentials: 'same-origin', ...options, headers, }); @@ -33,6 +34,11 @@ async function request(url, options = {}) { // 业务失败 if (json.status !== 0) { + if (resp.status === 401 && url !== '/auth/login' && url !== '/auth/me') { + window.dispatchEvent(new CustomEvent('wind-auth-required', { + detail: { message: json.msg || '请先登录' }, + })); + } throw new Error(json.msg || '请求失败'); } return json.data; @@ -54,6 +60,39 @@ export function getHealth() { return request('/system/health'); } +export function login(username, password) { + return request('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +export function logout() { + return request('/auth/logout', { method: 'POST' }); +} + +export function getCurrentUser() { + return request('/auth/me'); +} + +export function getAccounts() { + return request('/admin/accounts'); +} + +export function createAccount(payload) { + return request('/admin/accounts', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateAccount(username, payload) { + return request(`/admin/accounts/${encodeURIComponent(username)}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + /** * 获取计算方案列表 * @returns {Promise<{default_scheme_id: string, schemes: Array}>} @@ -108,8 +147,8 @@ export function startWindJob(payload) { } /** - * 上传标准化后的数据分片 - * @param {{job_id: string, chunk_index: number, rows: Array}} payload + * 上传完整原始数据分片,服务端按任务列映射生成计算行。 + * @param {{job_id: string, chunk_index: number, raw_rows: Array}} payload * @returns {Promise<{accepted_rows: number}>} */ export function uploadWindChunk(payload) { @@ -131,6 +170,11 @@ export function finishWindJob(payload) { }); } +/** 获取单台风机的完整有效点与过滤点。 */ +export function getWindFanPoints(jobId, fanId) { + return request(`/wind/jobs/${encodeURIComponent(jobId)}/fans/${encodeURIComponent(fanId)}/points`); +} + /** * 清理未完成的计算任务 * @param {string} jobId @@ -147,6 +191,7 @@ export async function downloadWindReport(jobId, payload) { const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, { method: 'POST', cache: 'no-store', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); @@ -154,11 +199,19 @@ export async function downloadWindReport(jobId, payload) { const text = await resp.text(); try { const json = JSON.parse(text); + if (resp.status === 401) { + window.dispatchEvent(new CustomEvent('wind-auth-required', { + detail: { message: json.msg || '请先登录' }, + })); + } throw new Error(json.msg || '完整报告生成失败'); } catch (error) { if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试'); throw error; } } - return resp.blob(); + return { + blob: await resp.blob(), + serverTiming: resp.headers.get('Server-Timing') || '', + }; } diff --git a/frontend/web_app/src/utils/powerCurveLegendLayout.js b/frontend/web_app/src/utils/powerCurveLegendLayout.js new file mode 100644 index 0000000..1a0d4b3 --- /dev/null +++ b/frontend/web_app/src/utils/powerCurveLegendLayout.js @@ -0,0 +1,39 @@ +function clamp(value, minimum, maximum) { + if (maximum < minimum) return minimum; + return Math.min(Math.max(value, minimum), maximum); +} + +export function resolvePowerCurveLegendLayout({ + plotLeft, + plotTop, + plotWidth, + plotHeight, + fontSize, + entries, + textWidths, +}) { + const inset = 18; + const markerWidth = 20; + const labelOffset = 30; + const textHeight = Math.max(1, Math.ceil(fontSize * 1.2)); + const lineHeight = Math.max(34, textHeight + 10); + const legendWidth = markerWidth + labelOffset + Math.max(0, ...textWidths); + const legendHeight = textHeight + Math.max(0, entries.length - 1) * lineHeight; + const plotRight = plotLeft + plotWidth; + const plotBottom = plotTop + plotHeight; + const minLeft = plotLeft + inset; + const minTop = plotTop + inset; + const desiredLeft = plotLeft + 22; + const desiredTop = plotTop + Math.max(30, fontSize + 10); + + return { + left: clamp(desiredLeft, minLeft, plotRight - inset - legendWidth), + top: clamp(desiredTop, minTop, plotBottom - inset - legendHeight), + markerWidth, + labelOffset, + textHeight, + lineHeight, + width: legendWidth, + height: legendHeight, + }; +} diff --git a/frontend/web_app/src/utils/powerCurveLegendLayout.test.js b/frontend/web_app/src/utils/powerCurveLegendLayout.test.js new file mode 100644 index 0000000..b91e4e5 --- /dev/null +++ b/frontend/web_app/src/utils/powerCurveLegendLayout.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolvePowerCurveLegendLayout } from './powerCurveLegendLayout.js'; + +function assertInsidePlot(layout, { left, top, width, height }) { + assert.ok(layout.left >= left); + assert.ok(layout.top >= top); + assert.ok(layout.left + layout.width <= left + width); + assert.ok(layout.top + layout.height <= top + height); +} + +test('keeps three-entry legends inside the plot at every supported text scale', () => { + for (const fontSize of [18, 24, 36]) { + const plot = { left: 160, top: 120, width: 1570, height: 905 }; + const layout = resolvePowerCurveLegendLayout({ + plotLeft: plot.left, + plotTop: plot.top, + plotWidth: plot.width, + plotHeight: plot.height, + fontSize, + entries: [['散点数据', 'scatter'], ['实际功率', 'actual'], ['设计功率', 'design']], + textWidths: [fontSize * 4, fontSize * 4, fontSize * 4], + }); + assertInsidePlot(layout, plot); + } +}); + +test('keeps a four-entry legend inside the plot', () => { + const plot = { left: 160, top: 142, width: 1570, height: 861 }; + const layout = resolvePowerCurveLegendLayout({ + plotLeft: plot.left, + plotTop: plot.top, + plotWidth: plot.width, + plotHeight: plot.height, + fontSize: 36, + entries: [['散点数据', 'scatter'], ['实际功率', 'actual'], ['设计功率', 'design'], ['滤除点', 'filtered']], + textWidths: [144, 144, 144, 108], + }); + assertInsidePlot(layout, plot); +}); diff --git a/frontend/web_app/src/utils/powerCurveModel.js b/frontend/web_app/src/utils/powerCurveModel.js index be66a13..bdb161b 100644 --- a/frontend/web_app/src/utils/powerCurveModel.js +++ b/frontend/web_app/src/utils/powerCurveModel.js @@ -101,6 +101,17 @@ export function getDrawableActualCurve(actualCurve = []) { .sort((left, right) => left.wind_speed - right.wind_speed); } +export function hasDrawablePowerCurveData({ + actualCurve = [], + designCurve = [], + scatterPoints = [], + filteredPoints = [], + showFiltered = false, +} = {}) { + return Boolean(actualCurve.length || designCurve.length || scatterPoints.length || + (showFiltered && filteredPoints.length)); +} + function interpolatePowerAtWindSpeed(designCurve, windSpeed) { if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON || windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) { diff --git a/frontend/web_app/src/utils/powerCurveModel.test.js b/frontend/web_app/src/utils/powerCurveModel.test.js index 4d0eea8..41e8ef7 100644 --- a/frontend/web_app/src/utils/powerCurveModel.test.js +++ b/frontend/web_app/src/utils/powerCurveModel.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildActualCurveFromDesign, getDrawableActualCurve } from './powerCurveModel.js'; +import { + buildActualCurveFromDesign, + getDrawableActualCurve, + hasDrawablePowerCurveData, +} from './powerCurveModel.js'; test('getDrawableActualCurve skips empty design bins and keeps adjacent valid points', () => { const actualCurve = buildActualCurveFromDesign( @@ -27,3 +31,12 @@ test('getDrawableActualCurve skips empty design bins and keeps adjacent valid po ], ); }); + +test('allows a chart to render when only scatter points are available', () => { + assert.equal(hasDrawablePowerCurveData({ + actualCurve: [], + designCurve: [], + scatterPoints: [{ wind_speed: 8, active_power: 1500 }], + }), true); + assert.equal(hasDrawablePowerCurveData({}), false); +}); diff --git a/frontend/web_app/src/utils/windFieldMapping.js b/frontend/web_app/src/utils/windFieldMapping.js new file mode 100644 index 0000000..3534d19 --- /dev/null +++ b/frontend/web_app/src/utils/windFieldMapping.js @@ -0,0 +1,83 @@ +export const BASE_REQUIRED_FIELDS = [ + { key: 'time', label: '时间' }, + { key: 'fan_id', label: '风机编号' }, + { key: 'wind_speed', label: '风速' }, + { key: 'active_power', label: '有功功率' }, + { key: 'generator_speed', label: '发电机转速' }, +]; + +export const ROTOR_SPEED_FIELD = { key: 'rotor_speed', label: '叶轮转速' }; + +export const PITCH_REQUIRED_FIELDS = [ + { key: 'blade_pitch_1', label: '1#叶片角度' }, + { key: 'blade_pitch_2', label: '2#叶片角度' }, + { key: 'blade_pitch_3', label: '3#叶片角度' }, +]; + +export const REQUIRED_FIELDS = [ + ...BASE_REQUIRED_FIELDS, + ROTOR_SPEED_FIELD, + ...PITCH_REQUIRED_FIELDS, +]; + +const FIELD_HINTS = { + time: ['时间', 'time', 'timestamp', '日期'], + fan_id: ['风机编号', '机组编号', '机组名称', '风机名称', '风机', 'fan', 'turbine'], + wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'], + active_power: ['平均有功功率', '有功功率', 'active power', 'power'], + generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'], + rotor_speed: ['风轮实时转速', '叶轮实时转速', '风轮转速', '叶轮转速', '主轴转速', '转子转速', 'rotor speed', 'rotor rpm'], + blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'], + blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'], + blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'], +}; + +const FIELD_EXCLUDES = { + active_power: ['限功率', '限电', '时间', '累计'], + generator_speed: ['风轮', '叶轮', '主轴', '转子'], + rotor_speed: ['发电机'], +}; + +export function normalizeHeader(value) { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/\s+/g, '') + .replace(/[()()_\-./]/g, ''); +} + +export function inferWindMapping(headers) { + const normalized = headers.map((header) => ({ + header, + normalized: normalizeHeader(header), + })); + const mapping = {}; + + for (const field of REQUIRED_FIELDS) { + const hints = FIELD_HINTS[field.key].map(normalizeHeader); + const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader); + let best = null; + for (const item of normalized) { + if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) { + continue; + } + let score = 0; + hints.forEach((hint, index) => { + const weight = hints.length - index; + if (item.normalized === hint) { + score = Math.max(score, 100 + weight); + } else if (item.normalized.includes(hint)) { + score = Math.max(score, 60 + weight); + } else if (hint.includes(item.normalized)) { + score = Math.max(score, 20 + weight); + } + }); + if (!best || score > best.score) { + best = { ...item, score }; + } + } + mapping[field.key] = best && best.score > 0 ? best.header : ''; + } + + return mapping; +} diff --git a/frontend/web_app/src/utils/windFieldMapping.test.js b/frontend/web_app/src/utils/windFieldMapping.test.js new file mode 100644 index 0000000..be39f57 --- /dev/null +++ b/frontend/web_app/src/utils/windFieldMapping.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { inferWindMapping } from './windFieldMapping.js'; + +test('maps generator and rotor speed to distinct Chinese columns', () => { + const mapping = inferWindMapping([ + '时间', + '机组名称', + '平均风速(m/s)', + '平均有功功率(kW)', + '发电机转速(rpm)', + '风轮实时转速(rpm)', + ]); + + assert.equal(mapping.generator_speed, '发电机转速(rpm)'); + assert.equal(mapping.rotor_speed, '风轮实时转速(rpm)'); + assert.equal(mapping.fan_id, '机组名称'); +}); + +test('does not use a generator speed column as rotor speed', () => { + const mapping = inferWindMapping(['发电机转速(rpm)']); + + assert.equal(mapping.generator_speed, '发电机转速(rpm)'); + assert.equal(mapping.rotor_speed, ''); +});