功能: 完善账号、工作台与功率计算方案
- 新增账号管理、有效期、启停控制与登录工作台布局 - 新增方案三叶轮转速计算及字段映射和报告支持 - 修复功率曲线断线、图例越界及方案一散点绘制 - 优化计算进度、浅色主题和当前方案提示 - 补充前端测试与部署依赖配置
This commit is contained in:
@@ -35,6 +35,8 @@ endif()
|
|||||||
|
|
||||||
find_package(Drogon CONFIG REQUIRED)
|
find_package(Drogon CONFIG REQUIRED)
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
find_path(SQLITE3_INCLUDE_DIR sqlite3.h REQUIRED)
|
||||||
|
find_library(SQLITE3_LIBRARY sqlite3 REQUIRED)
|
||||||
|
|
||||||
# libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。
|
# libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。
|
||||||
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
@@ -56,10 +58,12 @@ add_executable(wind_server ${SOURCES})
|
|||||||
target_include_directories(wind_server PRIVATE
|
target_include_directories(wind_server PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||||
${REPO_ROOT}/third_party
|
${REPO_ROOT}/third_party
|
||||||
|
${SQLITE3_INCLUDE_DIR}
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(wind_server PRIVATE
|
target_link_libraries(wind_server PRIVATE
|
||||||
Drogon::Drogon
|
Drogon::Drogon
|
||||||
|
${SQLITE3_LIBRARY}
|
||||||
Threads::Threads
|
Threads::Threads
|
||||||
xlsxwriter
|
xlsxwriter
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
#include "AuthManager.h"
|
||||||
|
|
||||||
|
#include <trantor/utils/Logger.h>
|
||||||
|
|
||||||
|
#include <ctime>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <regex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <drogon/utils/Utilities.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
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<const char*>(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<std::time_t>(-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<unsigned char>(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<AccountRecord> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mutex_);
|
||||||
|
sessions_.erase(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json AuthManager::ListAccounts() {
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::string>& password,
|
||||||
|
const std::string& expires_on,
|
||||||
|
const std::optional<bool>& enabled,
|
||||||
|
std::string& error) {
|
||||||
|
std::lock_guard<std::mutex> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#ifndef AUTHMANAGER_H
|
||||||
|
#define AUTHMANAGER_H
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
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<std::string>& password,
|
||||||
|
const std::string& expires_on,
|
||||||
|
const std::optional<bool>& 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<std::string, Session> sessions_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#include "AuthController.h"
|
||||||
|
|
||||||
|
#include <trantor/utils/Logger.h>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <drogon/Cookie.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include "auth/AuthManager.h"
|
||||||
|
#include "utils/ResponseUtil.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
std::optional<json> 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<AuthUser> 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& callback) {
|
||||||
|
if (!RequireAdmin(req, callback).has_value()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SendSuccess(callback, json{{"accounts", AuthManager::Instance().ListAccounts()}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthController::CreateAccount(
|
||||||
|
const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& 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<void(const drogon::HttpResponsePtr&)>&& 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<std::string> password;
|
||||||
|
if (body->contains("password") && (*body)["password"].is_string()) {
|
||||||
|
password = (*body)["password"].get<std::string>();
|
||||||
|
}
|
||||||
|
std::optional<bool> enabled;
|
||||||
|
if (body->contains("enabled") && (*body)["enabled"].is_boolean()) {
|
||||||
|
enabled = (*body)["enabled"].get<bool>();
|
||||||
|
}
|
||||||
|
std::string error;
|
||||||
|
if (!AuthManager::Instance().UpdateAccount(
|
||||||
|
username, password, body->value("expires_on", ""), enabled, error)) {
|
||||||
|
SendError(callback, 1, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SendSuccess(callback);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef AUTHCONTROLLER_H
|
||||||
|
#define AUTHCONTROLLER_H
|
||||||
|
|
||||||
|
#include <drogon/HttpController.h>
|
||||||
|
|
||||||
|
class AuthController : public drogon::HttpController<AuthController, false> {
|
||||||
|
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<void(const drogon::HttpResponsePtr&)>&& callback);
|
||||||
|
void Logout(const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
|
||||||
|
void GetCurrentUser(const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
|
||||||
|
void ListAccounts(const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
|
||||||
|
void CreateAccount(const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
|
||||||
|
void UpdateAccount(const drogon::HttpRequestPtr& req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr&)>&& callback,
|
||||||
|
const std::string& username);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <random>
|
#include <random>
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <xlsxwriter/format.h>
|
#include <xlsxwriter/format.h>
|
||||||
@@ -36,11 +38,15 @@ constexpr auto kUploadIdleTimeout = std::chrono::minutes(30);
|
|||||||
constexpr auto kCompletedJobRetention = std::chrono::hours(24);
|
constexpr auto kCompletedJobRetention = std::chrono::hours(24);
|
||||||
constexpr const char* kDefaultSchemeId = "scheme_one";
|
constexpr const char* kDefaultSchemeId = "scheme_one";
|
||||||
constexpr const char* kSchemeTwoId = "scheme_two";
|
constexpr const char* kSchemeTwoId = "scheme_two";
|
||||||
|
constexpr const char* kSchemeThreeId = "scheme_three";
|
||||||
constexpr const char* kSchemeOneName = "方案一";
|
constexpr const char* kSchemeOneName = "方案一";
|
||||||
constexpr const char* kSchemeTwoName = "方案二";
|
constexpr const char* kSchemeTwoName = "方案二";
|
||||||
|
constexpr const char* kSchemeThreeName = "方案三";
|
||||||
constexpr const char* kSchemeOneDefaultDescription = "通用方案";
|
constexpr const char* kSchemeOneDefaultDescription = "通用方案";
|
||||||
constexpr const char* kSchemeTwoDefaultDescription =
|
constexpr const char* kSchemeTwoDefaultDescription =
|
||||||
"桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数";
|
"桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数";
|
||||||
|
constexpr const char* kSchemeThreeDefaultDescription =
|
||||||
|
"叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比";
|
||||||
|
|
||||||
struct RawRow {
|
struct RawRow {
|
||||||
std::string time;
|
std::string time;
|
||||||
@@ -48,6 +54,7 @@ struct RawRow {
|
|||||||
double wind_speed = 0.0;
|
double wind_speed = 0.0;
|
||||||
double active_power = 0.0;
|
double active_power = 0.0;
|
||||||
double generator_speed = 0.0;
|
double generator_speed = 0.0;
|
||||||
|
double rotor_speed = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ValidRow {
|
struct ValidRow {
|
||||||
@@ -58,6 +65,7 @@ struct ValidRow {
|
|||||||
double wind_speed = 0.0;
|
double wind_speed = 0.0;
|
||||||
double active_power = 0.0;
|
double active_power = 0.0;
|
||||||
double generator_speed = 0.0;
|
double generator_speed = 0.0;
|
||||||
|
double rotor_speed = 0.0;
|
||||||
double blade_pitch_1 = 0.0;
|
double blade_pitch_1 = 0.0;
|
||||||
double blade_pitch_2 = 0.0;
|
double blade_pitch_2 = 0.0;
|
||||||
double blade_pitch_3 = 0.0;
|
double blade_pitch_3 = 0.0;
|
||||||
@@ -125,6 +133,17 @@ struct SchemeInfo {
|
|||||||
double rated_generator_speed = 1755.0;
|
double rated_generator_speed = 1755.0;
|
||||||
double rated_power = 2000.0;
|
double rated_power = 2000.0;
|
||||||
double scheme_two_report_wind_speed_interval = 0.25;
|
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;
|
constexpr double kRatedCornerWindBefore = 0.5;
|
||||||
@@ -170,6 +189,14 @@ fs::path JobResultPath(const std::string& job_id) {
|
|||||||
return JobDir(job_id) / "result.json";
|
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::mutex g_task_mutex;
|
||||||
std::string g_active_job_id;
|
std::string g_active_job_id;
|
||||||
std::chrono::steady_clock::time_point g_active_since;
|
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;
|
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<std::string> PointIdSet(const json& body, const char* field) {
|
||||||
|
std::unordered_set<std::string> 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<std::string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
fs::path SchemeConfigPath() {
|
fs::path SchemeConfigPath() {
|
||||||
return fs::path("data") / "wind_schemes.json";
|
return fs::path("data") / "wind_schemes.json";
|
||||||
}
|
}
|
||||||
@@ -485,7 +534,13 @@ bool SaveChartOptionsToFile(const json& options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string NormalizeSchemeId(const std::string& scheme_id) {
|
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<SchemeInfo> DefaultSchemes() {
|
std::vector<SchemeInfo> DefaultSchemes() {
|
||||||
@@ -498,7 +553,12 @@ std::vector<SchemeInfo> DefaultSchemes() {
|
|||||||
scheme_two.id = kSchemeTwoId;
|
scheme_two.id = kSchemeTwoId;
|
||||||
scheme_two.name = kSchemeTwoName;
|
scheme_two.name = kSchemeTwoName;
|
||||||
scheme_two.description = kSchemeTwoDefaultDescription;
|
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<SchemeInfo> FindScheme(const std::vector<SchemeInfo>& schemes,
|
std::optional<SchemeInfo> FindScheme(const std::vector<SchemeInfo>& schemes,
|
||||||
@@ -538,6 +598,20 @@ json SchemeToJson(const SchemeInfo& scheme) {
|
|||||||
data["parameters"]["rated_power"] = scheme.rated_power;
|
data["parameters"]["rated_power"] = scheme.rated_power;
|
||||||
data["parameters"]["report_wind_speed_interval"] =
|
data["parameters"]["report_wind_speed_interval"] =
|
||||||
scheme.scheme_two_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;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -615,6 +689,40 @@ std::vector<SchemeInfo> LoadSchemes() {
|
|||||||
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
||||||
scheme.scheme_two_report_wind_speed_interval = value.value();
|
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<std::time_t> ParseTime(std::string value) {
|
|||||||
return std::mktime(&tm);
|
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<std::time_t>((value.get<double>() - 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<json> 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<std::string>() == 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<std::string, int>& counters) {
|
json CounterJson(const std::unordered_map<std::string, int>& counters) {
|
||||||
json data = json::object();
|
json data = json::object();
|
||||||
for (const auto& item : counters) {
|
for (const auto& item : counters) {
|
||||||
@@ -851,6 +1006,10 @@ bool IsSchemeTwo(const CalculationOptions& options) {
|
|||||||
return options.scheme_id == kSchemeTwoId;
|
return options.scheme_id == kSchemeTwoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsSchemeThree(const CalculationOptions& options) {
|
||||||
|
return options.scheme_id == kSchemeThreeId;
|
||||||
|
}
|
||||||
|
|
||||||
void AddInvalid(std::unordered_map<std::string, int>& counters, const std::string& reason) {
|
void AddInvalid(std::unordered_map<std::string, int>& counters, const std::string& reason) {
|
||||||
counters[reason] += 1;
|
counters[reason] += 1;
|
||||||
}
|
}
|
||||||
@@ -1021,18 +1180,21 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
|
|||||||
max_wind = std::max(max_wind, row.wind_speed);
|
max_wind = std::max(max_wind, row.wind_speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::map<long long, std::vector<ValidRow>> buckets;
|
||||||
|
for (const auto& row : rows) {
|
||||||
|
const auto bucket = static_cast<long long>(std::floor(
|
||||||
|
(row.wind_speed - min_wind) / options.cleaning_wind_speed_step + 1e-9));
|
||||||
|
buckets[bucket].push_back(row);
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ValidRow> result;
|
std::vector<ValidRow> result;
|
||||||
result.reserve(rows.size());
|
result.reserve(rows.size());
|
||||||
for (double interval = min_wind; interval <= max_wind;
|
for (const auto& item : buckets) {
|
||||||
interval += options.cleaning_wind_speed_step) {
|
const auto& interval_rows = item.second;
|
||||||
std::vector<ValidRow> interval_rows;
|
|
||||||
std::vector<double> values;
|
std::vector<double> values;
|
||||||
for (const auto& row : rows) {
|
values.reserve(interval_rows.size());
|
||||||
if (row.wind_speed >= interval &&
|
for (const auto& row : interval_rows) {
|
||||||
row.wind_speed < interval + options.cleaning_wind_speed_step) {
|
values.push_back(value_getter(row));
|
||||||
interval_rows.push_back(row);
|
|
||||||
values.push_back(value_getter(row));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interval_rows.empty()) {
|
if (interval_rows.empty()) {
|
||||||
@@ -1074,18 +1236,22 @@ std::vector<CurveBin> BuildMedianCurveBins(const std::vector<ValidRow>& rows,
|
|||||||
|
|
||||||
const double curve_min = 1.0 - wind_speed_step * 0.5;
|
const double curve_min = 1.0 - wind_speed_step * 0.5;
|
||||||
const double curve_max = 25.0 + wind_speed_step * 0.5;
|
const double curve_max = 25.0 + wind_speed_step * 0.5;
|
||||||
std::vector<CurveBin> bins;
|
const auto bucket_count = static_cast<size_t>(std::ceil((curve_max - curve_min) / wind_speed_step));
|
||||||
for (double start = curve_min; start < curve_max; start += wind_speed_step) {
|
std::vector<std::vector<double>> values_by_bucket(bucket_count);
|
||||||
const double end = start + wind_speed_step;
|
for (const auto& row : rows) {
|
||||||
std::vector<double> values;
|
const auto bucket = static_cast<long long>(std::ceil((row.wind_speed - curve_min) /
|
||||||
for (const auto& row : rows) {
|
wind_speed_step) - 1.0);
|
||||||
if (row.wind_speed > start && row.wind_speed <= end) {
|
if (bucket >= 0 && static_cast<size_t>(bucket) < values_by_bucket.size()) {
|
||||||
values.push_back(row.active_power);
|
values_by_bucket[static_cast<size_t>(bucket)].push_back(row.active_power);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
std::vector<CurveBin> bins;
|
||||||
|
for (size_t index = 0; index < values_by_bucket.size(); ++index) {
|
||||||
|
const auto& values = values_by_bucket[index];
|
||||||
if (!values.empty()) {
|
if (!values.empty()) {
|
||||||
CurveBin bin;
|
CurveBin bin;
|
||||||
bin.wind_speed = (start + end) / 2.0;
|
const double start = curve_min + static_cast<double>(index) * wind_speed_step;
|
||||||
|
bin.wind_speed = start + wind_speed_step / 2.0;
|
||||||
bin.median_power = Quantile(values, 0.5);
|
bin.median_power = Quantile(values, 0.5);
|
||||||
bin.sample_count = values.size();
|
bin.sample_count = values.size();
|
||||||
bins.push_back(bin);
|
bins.push_back(bin);
|
||||||
@@ -1371,14 +1537,14 @@ void WindPowerController::SaveSchemeDescription(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<json> scheme_one_parameters;
|
std::optional<json> scheme_base_parameters;
|
||||||
std::optional<double> grid_connected_speed;
|
std::optional<double> grid_connected_speed;
|
||||||
std::optional<double> rated_generator_speed;
|
std::optional<double> rated_generator_speed;
|
||||||
std::optional<double> rated_power;
|
std::optional<double> rated_power;
|
||||||
std::optional<double> report_wind_speed_interval;
|
std::optional<double> 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()) {
|
if (!body.value().contains("parameters") || !body.value()["parameters"].is_object()) {
|
||||||
SendError(callback, kErrorInvalidRequest, "方案一参数格式错误");
|
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auto& params = body.value()["parameters"];
|
const auto& params = body.value()["parameters"];
|
||||||
@@ -1392,24 +1558,25 @@ void WindPowerController::SaveSchemeDescription(
|
|||||||
};
|
};
|
||||||
if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") ||
|
if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") ||
|
||||||
!valid_positive("power_step") || !valid_positive("cleaning_wind_speed_step") ||
|
!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_positive("report_wind_speed_interval") ||
|
||||||
!valid_non_negative("wind_speed_change_threshold") ||
|
!valid_non_negative("wind_speed_change_threshold") ||
|
||||||
!valid_non_negative("iqr_lower_multiplier") ||
|
!valid_non_negative("iqr_lower_multiplier") ||
|
||||||
!valid_non_negative("iqr_upper_multiplier") ||
|
!valid_non_negative("iqr_upper_multiplier") ||
|
||||||
!valid_non_negative("minimum_generator_speed") ||
|
!valid_non_negative("minimum_generator_speed") ||
|
||||||
!valid_non_negative("generator_speed_k")) {
|
!valid_non_negative("generator_speed_k")) {
|
||||||
SendError(callback, kErrorInvalidRequest, "方案一参数必须为合法数值");
|
SendError(callback, kErrorInvalidRequest, "方案参数必须为合法数值");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 ||
|
if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 ||
|
||||||
GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 ||
|
GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 ||
|
||||||
GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 ||
|
GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 ||
|
||||||
GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) {
|
GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) {
|
||||||
SendError(callback, kErrorInvalidRequest, "方案一参数超出允许范围");
|
SendError(callback, kErrorInvalidRequest, "方案参数超出允许范围");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheme_one_parameters = params;
|
scheme_base_parameters = params;
|
||||||
} else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) {
|
} else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) {
|
||||||
if (!body.value()["parameters"].is_object()) {
|
if (!body.value()["parameters"].is_object()) {
|
||||||
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
||||||
@@ -1436,7 +1603,7 @@ void WindPowerController::SaveSchemeDescription(
|
|||||||
if (scheme.id == normalized_id) {
|
if (scheme.id == normalized_id) {
|
||||||
scheme.description = description.value();
|
scheme.description = description.value();
|
||||||
if (scheme.id == kDefaultSchemeId) {
|
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_power = GetDoubleField(params, "rated_power").value();
|
||||||
scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value();
|
scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value();
|
||||||
scheme.scheme_one_power_step = GetDoubleField(params, "power_step").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 =
|
scheme.scheme_two_report_wind_speed_interval =
|
||||||
report_wind_speed_interval.value();
|
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;
|
updated = true;
|
||||||
break;
|
break;
|
||||||
@@ -1588,8 +1779,8 @@ void WindPowerController::UploadChunk(
|
|||||||
SendError(callback, kErrorJobNotFound, "计算任务不存在");
|
SendError(callback, kErrorJobNotFound, "计算任务不存在");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!body->contains("rows") || !(*body)["rows"].is_array()) {
|
if (!body->contains("raw_rows") || !(*body)["raw_rows"].is_array()) {
|
||||||
SendError(callback, kErrorInvalidRequest, "缺少 rows 参数");
|
SendError(callback, kErrorInvalidRequest, "缺少 raw_rows 参数");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (IsTaskBusyFor(job_id.value())) {
|
if (IsTaskBusyFor(job_id.value())) {
|
||||||
@@ -1597,22 +1788,26 @@ void WindPowerController::UploadChunk(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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 out(JobRowsPath(job_id.value()), std::ios::app);
|
||||||
std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app);
|
std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app);
|
||||||
int accepted = 0;
|
int accepted = 0;
|
||||||
for (const auto& row : (*body)["rows"]) {
|
for (const auto& raw_row : (*body)["raw_rows"]) {
|
||||||
if (!row.is_object()) {
|
if (!raw_row.is_object()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out << row.dump() << '\n';
|
raw_out << raw_row.dump() << '\n';
|
||||||
++accepted;
|
const auto row = BuildStandardRow(raw_row, metadata);
|
||||||
}
|
if (row.has_value()) {
|
||||||
out.close();
|
out << row->dump() << '\n';
|
||||||
if (body->contains("raw_rows") && (*body)["raw_rows"].is_array()) {
|
++accepted;
|
||||||
for (const auto& row : (*body)["raw_rows"]) {
|
|
||||||
if (row.is_object()) raw_out << row.dump() << '\n';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.close();
|
||||||
raw_out.close();
|
raw_out.close();
|
||||||
TouchTask(job_id.value());
|
TouchTask(job_id.value());
|
||||||
|
|
||||||
@@ -1645,6 +1840,7 @@ void WindPowerController::FinishJob(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TaskReleaseGuard finish_guard(job_id.value());
|
TaskReleaseGuard finish_guard(job_id.value());
|
||||||
|
const auto calculation_started = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
const CalculationOptions options = ParseOptions(*body);
|
const CalculationOptions options = ParseOptions(*body);
|
||||||
if (body->contains("options") && (*body)["options"].is_object() &&
|
if (body->contains("options") && (*body)["options"].is_object() &&
|
||||||
@@ -1668,6 +1864,7 @@ void WindPowerController::FinishJob(
|
|||||||
std::vector<ValidRow> parsed_rows;
|
std::vector<ValidRow> parsed_rows;
|
||||||
std::unordered_map<std::string, int> invalid_reasons;
|
std::unordered_map<std::string, int> invalid_reasons;
|
||||||
int raw_rows = 0;
|
int raw_rows = 0;
|
||||||
|
const auto read_started = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
std::ifstream in(JobRowsPath(job_id.value()));
|
std::ifstream in(JobRowsPath(job_id.value()));
|
||||||
@@ -1691,6 +1888,7 @@ void WindPowerController::FinishJob(
|
|||||||
const auto wind_speed = GetDoubleField(row, "wind_speed");
|
const auto wind_speed = GetDoubleField(row, "wind_speed");
|
||||||
const auto active_power = GetDoubleField(row, "active_power");
|
const auto active_power = GetDoubleField(row, "active_power");
|
||||||
const auto generator_speed = GetDoubleField(row, "generator_speed");
|
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_1 = GetDoubleField(row, "blade_pitch_1");
|
||||||
const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2");
|
const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2");
|
||||||
const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3");
|
const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3");
|
||||||
@@ -1706,6 +1904,12 @@ void WindPowerController::FinishJob(
|
|||||||
AddInvalid(invalid_reasons, "invalid_generator_speed");
|
AddInvalid(invalid_reasons, "invalid_generator_speed");
|
||||||
continue;
|
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) &&
|
if (IsSchemeTwo(options) &&
|
||||||
(!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) ||
|
(!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) ||
|
||||||
!blade_pitch_2.has_value() || !std::isfinite(blade_pitch_2.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.wind_speed = wind_speed.value();
|
||||||
valid_row.active_power = active_power.value();
|
valid_row.active_power = active_power.value();
|
||||||
valid_row.generator_speed = generator_speed.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)) {
|
if (IsSchemeTwo(options)) {
|
||||||
valid_row.blade_pitch_1 = blade_pitch_1.value();
|
valid_row.blade_pitch_1 = blade_pitch_1.value();
|
||||||
valid_row.blade_pitch_2 = blade_pitch_2.value();
|
valid_row.blade_pitch_2 = blade_pitch_2.value();
|
||||||
@@ -1755,6 +1962,8 @@ void WindPowerController::FinishJob(
|
|||||||
SendError(callback, kErrorServer, "读取任务数据失败");
|
SendError(callback, kErrorServer, "读取任务数据失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const auto read_parse_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - read_started).count();
|
||||||
|
|
||||||
std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) {
|
std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) {
|
||||||
if (left.fan_id != right.fan_id) {
|
if (left.fan_id != right.fan_id) {
|
||||||
@@ -1787,8 +1996,7 @@ void WindPowerController::FinishJob(
|
|||||||
json fans = json::array();
|
json fans = json::array();
|
||||||
json curves = json::object();
|
json curves = json::object();
|
||||||
json bins = json::object();
|
json bins = json::object();
|
||||||
json scatter_points = json::object();
|
json point_files = json::object();
|
||||||
json filtered_points = json::object();
|
|
||||||
json estimated_params = json::object();
|
json estimated_params = json::object();
|
||||||
|
|
||||||
std::vector<std::string> fan_ids;
|
std::vector<std::string> fan_ids;
|
||||||
@@ -1809,7 +2017,10 @@ void WindPowerController::FinishJob(
|
|||||||
int scheme_two_low_speed_pitch_count = 0;
|
int scheme_two_low_speed_pitch_count = 0;
|
||||||
int scheme_two_low_power_pitch_count = 0;
|
int scheme_two_low_power_pitch_count = 0;
|
||||||
int cleaned_rows_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);
|
fans.push_back(fan_id);
|
||||||
auto fan_rows = rows_by_fan[fan_id];
|
auto fan_rows = rows_by_fan[fan_id];
|
||||||
std::vector<RemovedPoint> fan_removed_points;
|
std::vector<RemovedPoint> fan_removed_points;
|
||||||
@@ -1836,9 +2047,11 @@ void WindPowerController::FinishJob(
|
|||||||
limit_power_count += removed;
|
limit_power_count += removed;
|
||||||
|
|
||||||
for (auto& row : fan_rows) {
|
for (auto& row : fan_rows) {
|
||||||
row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio *
|
const double speed = IsSchemeThree(options)
|
||||||
options.rotor_radius * 30.0 /
|
? row.rotor_speed
|
||||||
row.wind_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(
|
fan_rows = FilterByWindBinIqr(
|
||||||
@@ -1913,11 +2126,10 @@ void WindPowerController::FinishJob(
|
|||||||
point["wind_speed"] = row.wind_speed;
|
point["wind_speed"] = row.wind_speed;
|
||||||
point["active_power"] = row.active_power;
|
point["active_power"] = row.active_power;
|
||||||
point["generator_speed"] = row.generator_speed;
|
point["generator_speed"] = row.generator_speed;
|
||||||
|
point["rotor_speed"] = row.rotor_speed;
|
||||||
point["pitch_angle_average"] = row.pitch_angle_average;
|
point["pitch_angle_average"] = row.pitch_angle_average;
|
||||||
fan_scatter.push_back(point);
|
fan_scatter.push_back(point);
|
||||||
}
|
}
|
||||||
scatter_points[fan_id] = fan_scatter;
|
|
||||||
|
|
||||||
json fan_filtered = json::array();
|
json fan_filtered = json::array();
|
||||||
for (const auto& removed_point : fan_removed_points) {
|
for (const auto& removed_point : fan_removed_points) {
|
||||||
json point;
|
json point;
|
||||||
@@ -1926,11 +2138,19 @@ void WindPowerController::FinishJob(
|
|||||||
point["wind_speed"] = removed_point.row.wind_speed;
|
point["wind_speed"] = removed_point.row.wind_speed;
|
||||||
point["active_power"] = removed_point.row.active_power;
|
point["active_power"] = removed_point.row.active_power;
|
||||||
point["generator_speed"] = removed_point.row.generator_speed;
|
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["pitch_angle_average"] = removed_point.row.pitch_angle_average;
|
||||||
point["reason"] = removed_point.reason;
|
point["reason"] = removed_point.reason;
|
||||||
fan_filtered.push_back(point);
|
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_curve = json::array();
|
||||||
json fan_bins_json = json::array();
|
json fan_bins_json = json::array();
|
||||||
@@ -2008,9 +2228,14 @@ void WindPowerController::FinishJob(
|
|||||||
data["fans"] = fans;
|
data["fans"] = fans;
|
||||||
data["curves"] = curves;
|
data["curves"] = curves;
|
||||||
data["bins"] = bins;
|
data["bins"] = bins;
|
||||||
data["scatter_points"] = scatter_points;
|
data["point_files"] = point_files;
|
||||||
data["filtered_points"] = filtered_points;
|
|
||||||
data["estimated_params"] = estimated_params;
|
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::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - cleaning_started).count();
|
||||||
|
const auto calculation_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - calculation_started).count();
|
||||||
|
data["timings_ms"]["calculation"] = calculation_ms;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc);
|
std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc);
|
||||||
@@ -2020,7 +2245,49 @@ void WindPowerController::FinishJob(
|
|||||||
return;
|
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<void(const HttpResponsePtr&)>&& 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(
|
void WindPowerController::ExportReport(
|
||||||
@@ -2038,8 +2305,7 @@ void WindPowerController::ExportReport(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auto fan_id = GetStringField(*body, "fan_id");
|
const auto fan_id = GetStringField(*body, "fan_id");
|
||||||
if (!fan_id.has_value() || !body->contains("effective_rows") ||
|
if (!fan_id.has_value() || !body->contains("report_rows") ||
|
||||||
!(*body)["effective_rows"].is_array() || !body->contains("report_rows") ||
|
|
||||||
!(*body)["report_rows"].is_array()) {
|
!(*body)["report_rows"].is_array()) {
|
||||||
SendError(callback, kErrorInvalidRequest, "报告参数不完整");
|
SendError(callback, kErrorInvalidRequest, "报告参数不完整");
|
||||||
return;
|
return;
|
||||||
@@ -2049,6 +2315,22 @@ void WindPowerController::ExportReport(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TaskReleaseGuard report_guard(job_id);
|
TaskReleaseGuard report_guard(job_id);
|
||||||
|
const auto export_started = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
const auto cache_key = std::to_string(std::hash<std::string>{}(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::milliseconds>(
|
||||||
|
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 {
|
try {
|
||||||
json metadata;
|
json metadata;
|
||||||
@@ -2075,7 +2357,26 @@ void WindPowerController::ExportReport(
|
|||||||
return;
|
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{};
|
lxw_workbook_options options{};
|
||||||
const auto temp_dir = JobDir(job_id).string();
|
const auto temp_dir = JobDir(job_id).string();
|
||||||
options.constant_memory = LXW_TRUE;
|
options.constant_memory = LXW_TRUE;
|
||||||
@@ -2092,23 +2393,41 @@ void WindPowerController::ExportReport(
|
|||||||
|
|
||||||
lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据");
|
lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据");
|
||||||
worksheet_freeze_panes(detail, 1, 0);
|
worksheet_freeze_panes(detail, 1, 0);
|
||||||
const bool include_pitch = result.value("scheme", json::object()).value("id", "") == kSchemeTwoId;
|
const auto report_scheme_id =
|
||||||
const std::vector<std::string> detail_headers = include_pitch
|
result.value("scheme", json::object()).value("id", "");
|
||||||
? std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"}
|
const bool include_pitch = report_scheme_id == kSchemeTwoId;
|
||||||
: std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"};
|
const bool include_rotor_speed = report_scheme_id == kSchemeThreeId;
|
||||||
|
std::vector<std::string> 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) {
|
for (size_t i = 0; i < detail_headers.size(); ++i) {
|
||||||
worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format);
|
worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format);
|
||||||
worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr);
|
worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr);
|
||||||
}
|
}
|
||||||
lxw_row_t detail_row = 1;
|
lxw_row_t detail_row = 1;
|
||||||
for (const auto& point : (*body)["effective_rows"]) {
|
for (const auto& point : effective_rows) {
|
||||||
if (!point.is_object()) continue;
|
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, 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_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, 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, 3, point.value("generator_speed", 0.0), number_format);
|
||||||
worksheet_write_number(detail, detail_row, 4, point.value("wind_speed", 0.0), number_format);
|
lxw_col_t detail_column = 4;
|
||||||
if (include_pitch) worksheet_write_number(detail, detail_row, 5, point.value("pitch_angle_average", 0.0), number_format);
|
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;
|
++detail_row;
|
||||||
}
|
}
|
||||||
worksheet_autofilter(detail, 0, 0, std::max<lxw_row_t>(1, detail_row - 1), detail_headers.size() - 1);
|
worksheet_autofilter(detail, 0, 0, std::max<lxw_row_t>(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);
|
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";
|
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);
|
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_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);
|
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)";
|
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);
|
&image_options);
|
||||||
}
|
}
|
||||||
if (workbook_close(workbook) != LXW_NO_ERROR) throw std::runtime_error("写入 Excel 文件失败");
|
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::milliseconds>(
|
||||||
|
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",
|
"完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx",
|
||||||
CT_CUSTOM,
|
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&) {
|
} catch (const std::exception&) {
|
||||||
SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试");
|
SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public:
|
|||||||
ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post);
|
ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post);
|
||||||
ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", 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::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::ExportReport, "/api/wind/jobs/{1}/report", Post);
|
||||||
ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete);
|
ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete);
|
||||||
ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get);
|
ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get);
|
||||||
@@ -39,6 +41,10 @@ public:
|
|||||||
std::function<void(const HttpResponsePtr&)>&& callback);
|
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||||
void FinishJob(const HttpRequestPtr& req,
|
void FinishJob(const HttpRequestPtr& req,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback);
|
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||||
|
void GetFanPoints(const HttpRequestPtr& req,
|
||||||
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
||||||
|
const std::string& job_id,
|
||||||
|
const std::string& fan_id);
|
||||||
void ExportReport(const HttpRequestPtr& req,
|
void ExportReport(const HttpRequestPtr& req,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback,
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
||||||
const std::string& job_id);
|
const std::string& job_id);
|
||||||
|
|||||||
+42
-2
@@ -2,19 +2,59 @@
|
|||||||
#include <trantor/utils/Logger.h>
|
#include <trantor/utils/Logger.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "auth/AuthManager.h"
|
||||||
|
#include "controllers/AuthController.h"
|
||||||
#include "controllers/WindPowerController.h"
|
#include "controllers/WindPowerController.h"
|
||||||
|
#include "utils/ResponseUtil.h"
|
||||||
|
|
||||||
using namespace drogon;
|
using namespace drogon;
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
// 抑制 Drogon/trantor 内部日志噪声
|
// 保留任务耗时日志,便于定位上传、清洗与回传瓶颈。
|
||||||
trantor::Logger::setLogLevel(trantor::Logger::kFatal);
|
trantor::Logger::setLogLevel(trantor::Logger::kInfo);
|
||||||
|
|
||||||
// 加载 Drogon 配置(监听端口 / CORS / 静态资源根目录)
|
// 加载 Drogon 配置(监听端口 / CORS / 静态资源根目录)
|
||||||
LOG_INFO << "Loading server configuration...";
|
LOG_INFO << "Loading server configuration...";
|
||||||
app().loadConfigFile("config/server_config.json");
|
app().loadConfigFile("config/server_config.json");
|
||||||
|
if (!AuthManager::Instance().Initialize()) {
|
||||||
|
LOG_ERROR << "Failed to initialize account database";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
app().registerController(std::make_shared<AuthController>());
|
||||||
app().registerController(std::make_shared<WindPowerController>());
|
app().registerController(std::make_shared<WindPowerController>());
|
||||||
|
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,交由前端路由处理
|
// SPA 前端路由回退:未匹配路径统一返回 index.html,交由前端路由处理
|
||||||
app().setCustom404Page(
|
app().setCustom404Page(
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ SERVICE_NAME="${SERVICE_NAME}"
|
|||||||
mkdir -p "$HOME/wind_power/logs"
|
mkdir -p "$HOME/wind_power/logs"
|
||||||
sudo cp "/tmp/${SERVICE_NAME}.service" "/etc/systemd/system/${SERVICE_NAME}.service"
|
sudo cp "/tmp/${SERVICE_NAME}.service" "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
sudo apt-get update -qq 2>/dev/null || true
|
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 daemon-reload
|
||||||
sudo systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
sudo systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||||
sudo systemctl restart "${SERVICE_NAME}"
|
sudo systemctl restart "${SERVICE_NAME}"
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<!-- 六边形 + 内部连接节点:工业网络枢纽 -->
|
||||||
|
<path d="M16 2 L28 9 L28 23 L16 30 L4 23 L4 9 Z" stroke="#6366f1" stroke-width="2" fill="rgba(99,102,241,0.1)"/>
|
||||||
|
<circle cx="16" cy="10" r="2.5" fill="#6366f1"/>
|
||||||
|
<circle cx="10" cy="21" r="2.5" fill="#6366f1"/>
|
||||||
|
<circle cx="22" cy="21" r="2.5" fill="#6366f1"/>
|
||||||
|
<line x1="16" y1="10" x2="10" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||||
|
<line x1="16" y1="10" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||||
|
<line x1="10" y1="21" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 645 B |
@@ -20,6 +20,45 @@
|
|||||||
gap: 10px;
|
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 {
|
.homeHeader h1 {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@@ -475,6 +514,95 @@ select:focus {
|
|||||||
margin-top: 16px;
|
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 {
|
.headerPreview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -770,6 +898,12 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.currentSchemeBanner {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.fieldControl {
|
.fieldControl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -782,6 +916,8 @@ td {
|
|||||||
.summaryGrid {
|
.summaryGrid {
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (max-width: 560px) {
|
||||||
.chartSettingsGrid { grid-template-columns: 1fr; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 HomePage from './pages/HomePage';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import { getCurrentUser, logout } from './utils/api';
|
||||||
import './App.css';
|
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 <Navigate to="/settings" replace />;
|
||||||
|
}
|
||||||
|
if (location.pathname === '/admin/accounts' && !user.is_admin) {
|
||||||
|
return <Navigate to="/settings" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout user={user} onLogout={onLogout}>
|
||||||
|
{activeSection
|
||||||
|
? <HomePage activeSection={activeSection} />
|
||||||
|
: <AdminAccountsPage />}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="authLoading">正在检查登录状态…</div>;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={(
|
||||||
|
<LoginPage
|
||||||
|
initialMessage={authMessage}
|
||||||
|
onLogin={(currentUser) => {
|
||||||
|
setUser(currentUser);
|
||||||
|
setAuthMessage('');
|
||||||
|
navigate('/settings', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AuthenticatedApp user={user} onLogout={handleLogout} />;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<AppContent />
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
{/* SPA 回退:未匹配路由统一回到首页 */}
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ children, user, onLogout }) {
|
||||||
|
const navItems = user.is_admin
|
||||||
|
? [...NAV_ITEMS, { to: '/admin/accounts', icon: '👤', label: '账号管理' }]
|
||||||
|
: NAV_ITEMS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<nav className={styles.sidebar} aria-label="主导航">
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<img src="/logo-1.svg" alt="" className={styles.brandLogo} />
|
||||||
|
<span className={styles.brandText}>风电功率计算平台</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.nav}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => `${styles.navItem} ${isActive ? styles.navActive : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.navIcon} aria-hidden="true">{item.icon}</span>
|
||||||
|
<span className={styles.navLabel}>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={styles.mainContainer}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.headerSpacer} />
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={`${styles.roleIcon} ${user.is_admin ? styles.adminIcon : ''}`} title={user.is_admin ? '管理员' : '普通账号'}>
|
||||||
|
<UserIcon admin={user.is_admin} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.userName}>{user.username}</span>
|
||||||
|
{user.is_admin && <span className={styles.roleLabel}>管理员</span>}
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<button className={styles.logoutButton} type="button" onClick={onLogout}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className={styles.content}>{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="accountPage">
|
||||||
|
<div className="accountPageHeader">
|
||||||
|
<div><h1>账号管理</h1><p>创建账号并设置登录密码和有效期</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
{message && <div className="alert info">{message}</div>}
|
||||||
|
|
||||||
|
<section className="panel accountCreatePanel">
|
||||||
|
<h2>新增账号</h2>
|
||||||
|
<form className="accountForm" onSubmit={handleCreate}>
|
||||||
|
<label>
|
||||||
|
<span>账号</span>
|
||||||
|
<input
|
||||||
|
value={form.username}
|
||||||
|
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="accountExpiryField">
|
||||||
|
<span>有效期</span>
|
||||||
|
<div className="accountExpiryInputs">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.expires_on}
|
||||||
|
disabled={form.permanent}
|
||||||
|
onChange={(event) => setForm({ ...form, expires_on: event.target.value })}
|
||||||
|
/>
|
||||||
|
<label className="accountPermanentToggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.permanent}
|
||||||
|
onChange={(event) => setForm({ ...form, permanent: event.target.checked })}
|
||||||
|
/>
|
||||||
|
永久
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="primaryButton" type="submit" disabled={submitting}>新增账号</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panelHeader"><h2>已有账号</h2><span>{accounts.length} 个账号</span></div>
|
||||||
|
<div className="tableWrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>有效期</th><th>新密码</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<tr key={account.username}>
|
||||||
|
<td>{account.username}</td>
|
||||||
|
<td>{account.is_admin ? '管理员' : '普通账号'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`confidence ${!account.enabled || account.expired ? 'low' : 'ok'}`}>
|
||||||
|
{!account.enabled ? '已停用' : account.expired ? '已过期' : '正常'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{account.is_admin ? '永久' : (
|
||||||
|
<div className="tableExpiryEditor">
|
||||||
|
<input
|
||||||
|
className="tableInput"
|
||||||
|
type="date"
|
||||||
|
disabled={drafts[account.username]?.permanent}
|
||||||
|
value={drafts[account.username]?.expires_on || ''}
|
||||||
|
onChange={(event) => updateDraft(
|
||||||
|
account.username,
|
||||||
|
'expires_on',
|
||||||
|
event.target.value,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label className="accountPermanentToggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={drafts[account.username]?.permanent || false}
|
||||||
|
onChange={(event) => updateDraft(
|
||||||
|
account.username,
|
||||||
|
'permanent',
|
||||||
|
event.target.checked,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
永久
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{account.is_admin ? '—' : (
|
||||||
|
<input
|
||||||
|
className="tableInput"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="留空则不修改"
|
||||||
|
value={drafts[account.username]?.password || ''}
|
||||||
|
onChange={(event) => updateDraft(
|
||||||
|
account.username,
|
||||||
|
'password',
|
||||||
|
event.target.value,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!account.is_admin && (
|
||||||
|
<div className="accountActions">
|
||||||
|
<button
|
||||||
|
className="secondaryButton"
|
||||||
|
type="button"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => handleUpdate(account.username)}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={account.enabled ? 'accountDisableButton' : 'accountEnableButton'}
|
||||||
|
type="button"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => handleToggleEnabled(account)}
|
||||||
|
>
|
||||||
|
{account.enabled ? '停用' : '启用'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
deleteWindJob,
|
deleteWindJob,
|
||||||
downloadWindReport,
|
downloadWindReport,
|
||||||
finishWindJob,
|
finishWindJob,
|
||||||
|
getWindFanPoints,
|
||||||
getWindChartOptions,
|
getWindChartOptions,
|
||||||
getWindSchemes,
|
getWindSchemes,
|
||||||
saveWindChartOptions,
|
saveWindChartOptions,
|
||||||
@@ -17,33 +18,18 @@ import {
|
|||||||
alignDesignCurveToActualCurve,
|
alignDesignCurveToActualCurve,
|
||||||
buildPowerCurveModel,
|
buildPowerCurveModel,
|
||||||
getDrawableActualCurve,
|
getDrawableActualCurve,
|
||||||
|
hasDrawablePowerCurveData,
|
||||||
} from '../utils/powerCurveModel';
|
} 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 = [
|
const CALCULATION_STEPS = ['准备数据', '上传数据', '清洗计算', '计算完成'];
|
||||||
{ 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 DESIGN_FIELDS = [
|
const DESIGN_FIELDS = [
|
||||||
{ key: 'wind_speed', label: '风速' },
|
{ key: 'wind_speed', label: '风速' },
|
||||||
@@ -55,13 +41,14 @@ const DESIGN_FIELD_HINTS = {
|
|||||||
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
|
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHUNK_SIZE = 4000;
|
const MAX_UPLOAD_CHUNK_BYTES = 6 * 1024 * 1024;
|
||||||
const CHART_HEIGHT = 580;
|
const CHART_HEIGHT = 580;
|
||||||
const BRUSH_RADIUS = 10;
|
const BRUSH_RADIUS = 10;
|
||||||
const EDIT_TOOL_RESTORE = 'restore';
|
const EDIT_TOOL_RESTORE = 'restore';
|
||||||
const EDIT_TOOL_ERASE = 'erase';
|
const EDIT_TOOL_ERASE = 'erase';
|
||||||
const DEFAULT_SCHEME_ID = 'scheme_one';
|
const DEFAULT_SCHEME_ID = 'scheme_one';
|
||||||
const SCHEME_TWO_ID = 'scheme_two';
|
const SCHEME_TWO_ID = 'scheme_two';
|
||||||
|
const SCHEME_THREE_ID = 'scheme_three';
|
||||||
const DEFAULT_SCHEMES = [
|
const DEFAULT_SCHEMES = [
|
||||||
{
|
{
|
||||||
id: 'scheme_one',
|
id: 'scheme_one',
|
||||||
@@ -93,6 +80,24 @@ const DEFAULT_SCHEMES = [
|
|||||||
report_wind_speed_interval: 0.25,
|
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 = {
|
const DEFAULT_SCHEME_TWO_PARAMS = {
|
||||||
@@ -115,6 +120,19 @@ const DEFAULT_SCHEME_ONE_PARAMS = {
|
|||||||
gearbox_ratio: '162',
|
gearbox_ratio: '162',
|
||||||
report_wind_speed_interval: '0.25',
|
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 = [
|
const SCHEME_ONE_PARAM_FIELDS = [
|
||||||
{ key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' },
|
{ key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' },
|
||||||
{ key: 'rated_wind_speed', label: '额定风速', unit: 'm/s', min: '0', step: '0.01' },
|
{ 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: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' },
|
||||||
{ key: 'report_wind_speed_interval', label: '报告公式风速区间半宽', unit: 'm/s', 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 CHART_STATE_KEY = 'wind_power_chart_state_v1';
|
||||||
const DEFAULT_CHART_OPTIONS = {
|
const DEFAULT_CHART_OPTIONS = {
|
||||||
title: '#机组功率曲线',
|
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) {
|
function inferDesignMapping(headers) {
|
||||||
const normalized = headers.map((header) => ({
|
const normalized = headers.map((header) => ({
|
||||||
header,
|
header,
|
||||||
@@ -285,57 +262,10 @@ function inferDesignMapping(headers) {
|
|||||||
return mapping;
|
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) {
|
function pad(value) {
|
||||||
return String(value).padStart(2, '0');
|
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) {
|
function normalizeNumber(value) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
return Number.isFinite(value) ? value : null;
|
return Number.isFinite(value) ? value : null;
|
||||||
@@ -439,38 +369,35 @@ function getCell(row, headerIndex) {
|
|||||||
return row[headerIndex];
|
return row[headerIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStandardRows(files, mapping) {
|
function buildRawRows(files) {
|
||||||
const firstHeaders = files[0]?.headers || [];
|
|
||||||
const indexes = Object.fromEntries(
|
|
||||||
REQUIRED_FIELDS.map((field) => [field.key, firstHeaders.indexOf(mapping[field.key])]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = [];
|
|
||||||
const rawRows = [];
|
const rawRows = [];
|
||||||
for (const file of files) {
|
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) {
|
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({
|
rawRows.push({
|
||||||
file_name: file.file_name,
|
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) {
|
function hasMatchingHeaderSequence(files) {
|
||||||
@@ -491,12 +418,13 @@ function sanitizeFileName(value) {
|
|||||||
return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_');
|
return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportValidRowsToExcel(fanId, rows) {
|
function exportValidRowsToExcel(fanId, rows, includeRotorSpeed = false) {
|
||||||
const exportRows = (rows || []).map((row) => ({
|
const exportRows = (rows || []).map((row) => ({
|
||||||
风机编号: row.fan_id || fanId || '',
|
风机编号: row.fan_id || fanId || '',
|
||||||
采样时间: row.time || '',
|
采样时间: row.time || '',
|
||||||
平均功率: row.active_power ?? '',
|
平均功率: row.active_power ?? '',
|
||||||
平均转速: row.generator_speed ?? '',
|
平均转速: row.generator_speed ?? '',
|
||||||
|
...(includeRotorSpeed ? { 叶轮转速: row.rotor_speed ?? '' } : {}),
|
||||||
平均风速: row.wind_speed ?? '',
|
平均风速: row.wind_speed ?? '',
|
||||||
'3个叶片变桨角平均值': row.pitch_angle_average ?? '',
|
'3个叶片变桨角平均值': row.pitch_angle_average ?? '',
|
||||||
}));
|
}));
|
||||||
@@ -603,6 +531,10 @@ function schemeParamsToState(parameters) {
|
|||||||
parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed,
|
parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed,
|
||||||
),
|
),
|
||||||
rated_power: String(parameters?.rated_power ?? DEFAULT_SCHEME_TWO_PARAMS.rated_power),
|
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 }) {
|
function SummaryCards({ summary }) {
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
const cards = [
|
const cards = [
|
||||||
@@ -930,28 +893,44 @@ function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
|||||||
];
|
];
|
||||||
context.textAlign = 'left';
|
context.textAlign = 'left';
|
||||||
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
|
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
|
||||||
const legendLeft = padding.left + 22;
|
const legendLayout = resolvePowerCurveLegendLayout({
|
||||||
const legendTop = padding.top + Math.max(30, typography.legend + 10);
|
plotLeft: padding.left,
|
||||||
const legendLineHeight = Math.max(34, typography.legend + 12);
|
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) => {
|
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 isScatter = type === 'scatter';
|
||||||
const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null;
|
const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null;
|
||||||
if (isScatter) {
|
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) {
|
} else if (style) {
|
||||||
context.save();
|
context.save();
|
||||||
context.strokeStyle = style.color;
|
context.strokeStyle = style.color;
|
||||||
context.lineWidth = 3;
|
context.lineWidth = 3;
|
||||||
context.setLineDash(getLineDash(style.lineStyle));
|
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();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
drawMarker(context, legendLeft + 10, top, 'circle', 4, type);
|
drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, type);
|
||||||
}
|
}
|
||||||
context.fillStyle = textColor;
|
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');
|
return canvas.toDataURL('image/png');
|
||||||
}
|
}
|
||||||
@@ -1288,7 +1267,14 @@ function PowerCurveChart({
|
|||||||
chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current());
|
chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current());
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
const transientErasedPointIds = new Set();
|
||||||
let redrawFrameId = 0;
|
let redrawFrameId = 0;
|
||||||
@@ -1628,7 +1614,13 @@ function PowerCurveChart({
|
|||||||
}
|
}
|
||||||
}, [editMode]);
|
}, [editMode]);
|
||||||
|
|
||||||
if (!actualCurve.length && !validDesign.length) {
|
if (!hasDrawablePowerCurveData({
|
||||||
|
actualCurve,
|
||||||
|
designCurve: validDesign,
|
||||||
|
scatterPoints: validScatter,
|
||||||
|
filteredPoints: validFiltered,
|
||||||
|
showFiltered,
|
||||||
|
})) {
|
||||||
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1673,13 +1665,17 @@ function PowerCurveChart({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage({ activeSection = 'settings' }) {
|
||||||
const savedSessionState = useMemo(() => loadChartState(), []);
|
const savedSessionState = useMemo(() => loadChartState(), []);
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [mapping, setMapping] = useState({});
|
const [mapping, setMapping] = useState({});
|
||||||
const [reading, setReading] = useState(false);
|
const [reading, setReading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [progress, setProgress] = useState('');
|
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 [error, setError] = useState('');
|
||||||
const [result, setResult] = useState(() => savedSessionState.result || null);
|
const [result, setResult] = useState(() => savedSessionState.result || null);
|
||||||
const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || '');
|
const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || '');
|
||||||
@@ -1724,11 +1720,15 @@ export default function HomePage() {
|
|||||||
const [schemeMessage, setSchemeMessage] = useState('');
|
const [schemeMessage, setSchemeMessage] = useState('');
|
||||||
const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS);
|
const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS);
|
||||||
const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS);
|
const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS);
|
||||||
|
const [schemeThreeParams, setSchemeThreeParams] = useState(DEFAULT_SCHEME_THREE_PARAMS);
|
||||||
|
|
||||||
const headers = files[0]?.headers || [];
|
const headers = files[0]?.headers || [];
|
||||||
const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0];
|
const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0];
|
||||||
const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID;
|
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 missingFields = mappingFields.filter((field) => !mapping[field.key]);
|
||||||
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
|
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
|
||||||
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
|
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
|
||||||
@@ -1797,6 +1797,28 @@ export default function HomePage() {
|
|||||||
? result.estimated_params[selectedFan]
|
? result.estimated_params[selectedFan]
|
||||||
: null;
|
: 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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({
|
sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({
|
||||||
@@ -2030,7 +2052,7 @@ export default function HomePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExportingReport(true);
|
setExportingReport(true);
|
||||||
setReportExportProgress('正在服务器生成完整报告');
|
setReportExportProgress('正在生成报告图表');
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const chartRenderSnapshot = await chartSnapshotRef.current?.(false);
|
const chartRenderSnapshot = await chartSnapshotRef.current?.(false);
|
||||||
@@ -2038,13 +2060,19 @@ export default function HomePage() {
|
|||||||
throw new Error('图表尚未完成绘制,请稍后重试');
|
throw new Error('图表尚未完成绘制,请稍后重试');
|
||||||
}
|
}
|
||||||
const chartImageData = renderPowerCurvePng(chartRenderSnapshot);
|
const chartImageData = renderPowerCurvePng(chartRenderSnapshot);
|
||||||
const blob = await downloadWindReport(result.job_id, {
|
setReportExportProgress('正在上传图表和编辑结果');
|
||||||
|
const report = await downloadWindReport(result.job_id, {
|
||||||
fan_id: selectedFan,
|
fan_id: selectedFan,
|
||||||
effective_rows: effectiveScatter,
|
|
||||||
report_rows: reportCurveRows,
|
report_rows: reportCurveRows,
|
||||||
|
restored_point_ids: restoredPointIds[selectedFan] || [],
|
||||||
|
erased_point_ids: erasedPointIds[selectedFan] || [],
|
||||||
chart_image: chartImageData || '',
|
chart_image: chartImageData || '',
|
||||||
});
|
});
|
||||||
downloadReportBlob(blob, selectedFan);
|
setReportExportProgress('正在下载完整报告');
|
||||||
|
downloadReportBlob(report.blob, selectedFan);
|
||||||
|
if (report.serverTiming) {
|
||||||
|
setReportExportNotice(`报告已生成(${report.serverTiming})`);
|
||||||
|
}
|
||||||
setExportingReport(false);
|
setExportingReport(false);
|
||||||
setReportExportProgress('');
|
setReportExportProgress('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2056,12 +2084,13 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canExportFullReport,
|
canExportFullReport,
|
||||||
effectiveScatter,
|
|
||||||
exportingReport,
|
exportingReport,
|
||||||
reportExportDisabledReason,
|
reportExportDisabledReason,
|
||||||
reportCurveRows,
|
reportCurveRows,
|
||||||
selectedFan,
|
selectedFan,
|
||||||
result?.job_id,
|
result?.job_id,
|
||||||
|
restoredPointIds,
|
||||||
|
erasedPointIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelReportExport = useCallback(() => {
|
const handleCancelReportExport = useCallback(() => {
|
||||||
@@ -2090,6 +2119,8 @@ export default function HomePage() {
|
|||||||
setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters));
|
setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters));
|
||||||
const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID);
|
const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID);
|
||||||
setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters));
|
setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters));
|
||||||
|
const schemeThree = loadedSchemes.find((scheme) => scheme.id === SCHEME_THREE_ID);
|
||||||
|
setSchemeThreeParams(schemeThreeParamsToState(schemeThree?.parameters));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案');
|
setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案');
|
||||||
@@ -2111,6 +2142,8 @@ export default function HomePage() {
|
|||||||
setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters));
|
setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters));
|
||||||
} else if (value === SCHEME_TWO_ID) {
|
} else if (value === SCHEME_TWO_ID) {
|
||||||
setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters));
|
setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters));
|
||||||
|
} else if (value === SCHEME_THREE_ID) {
|
||||||
|
setSchemeThreeParams(schemeThreeParamsToState(nextScheme?.parameters));
|
||||||
}
|
}
|
||||||
setSchemeMessage('');
|
setSchemeMessage('');
|
||||||
}
|
}
|
||||||
@@ -2129,6 +2162,13 @@ export default function HomePage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSchemeThreeParamChange(field, value) {
|
||||||
|
setSchemeThreeParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveSchemeDescription() {
|
async function handleSaveSchemeDescription() {
|
||||||
if (!selectedScheme) return;
|
if (!selectedScheme) return;
|
||||||
|
|
||||||
@@ -2136,30 +2176,20 @@ export default function HomePage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setSchemeMessage('');
|
setSchemeMessage('');
|
||||||
try {
|
try {
|
||||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||||
field,
|
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||||
normalizeNumber(value),
|
|
||||||
]));
|
|
||||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||||
const reportWindSpeedInterval = normalizeNumber(schemeTwoParams.report_wind_speed_interval);
|
const reportWindSpeedInterval = normalizeNumber(schemeTwoParams.report_wind_speed_interval);
|
||||||
if (selectedScheme.id === DEFAULT_SCHEME_ID &&
|
if (selectedScheme.id === DEFAULT_SCHEME_ID &&
|
||||||
(!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 ||
|
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||||
!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
|
|
||||||
)))) {
|
|
||||||
throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||||
}
|
}
|
||||||
|
if (selectedScheme.id === SCHEME_THREE_ID &&
|
||||||
|
areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||||
|
throw new Error('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||||
|
}
|
||||||
if (selectedScheme.id === SCHEME_TWO_ID &&
|
if (selectedScheme.id === SCHEME_TWO_ID &&
|
||||||
(!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 ||
|
(!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 ||
|
||||||
!Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 ||
|
!Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 ||
|
||||||
@@ -2171,6 +2201,7 @@ export default function HomePage() {
|
|||||||
const data = await saveWindSchemeDescription(selectedScheme.id, {
|
const data = await saveWindSchemeDescription(selectedScheme.id, {
|
||||||
description: schemeDescription,
|
description: schemeDescription,
|
||||||
...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}),
|
...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}),
|
||||||
|
...(selectedScheme.id === SCHEME_THREE_ID ? { parameters: schemeThreeValues } : {}),
|
||||||
...(selectedScheme.id === SCHEME_TWO_ID ? {
|
...(selectedScheme.id === SCHEME_TWO_ID ? {
|
||||||
parameters: {
|
parameters: {
|
||||||
grid_connected_speed: gridConnectedSpeed,
|
grid_connected_speed: gridConnectedSpeed,
|
||||||
@@ -2189,6 +2220,8 @@ export default function HomePage() {
|
|||||||
setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters));
|
setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters));
|
||||||
} else if (savedScheme.id === SCHEME_TWO_ID) {
|
} else if (savedScheme.id === SCHEME_TWO_ID) {
|
||||||
setSchemeTwoParams(schemeParamsToState(savedScheme.parameters));
|
setSchemeTwoParams(schemeParamsToState(savedScheme.parameters));
|
||||||
|
} else if (savedScheme.id === SCHEME_THREE_ID) {
|
||||||
|
setSchemeThreeParams(schemeThreeParamsToState(savedScheme.parameters));
|
||||||
}
|
}
|
||||||
setSchemeMessage('已保存');
|
setSchemeMessage('已保存');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2205,6 +2238,10 @@ export default function HomePage() {
|
|||||||
setReading(true);
|
setReading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setProgress('');
|
||||||
|
setProgressPercent(0);
|
||||||
|
setProgressStage(-1);
|
||||||
|
setPerformanceTimings(null);
|
||||||
setSelectedFan('');
|
setSelectedFan('');
|
||||||
setShowFiltered(false);
|
setShowFiltered(false);
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
@@ -2221,7 +2258,7 @@ export default function HomePage() {
|
|||||||
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
|
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
|
||||||
}
|
}
|
||||||
setFiles(parsedFiles);
|
setFiles(parsedFiles);
|
||||||
setMapping(inferMapping(parsedFiles[0].headers));
|
setMapping(inferWindMapping(parsedFiles[0].headers));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || '读取 Excel 失败');
|
setError(err.message || '读取 Excel 失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2285,29 +2322,17 @@ export default function HomePage() {
|
|||||||
setError('请先上传文件并完成所有字段映射');
|
setError('请先上传文件并完成所有字段映射');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||||
field,
|
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||||
normalizeNumber(value),
|
if (!isSchemeTwo && !isSchemeThree &&
|
||||||
]));
|
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||||
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) {
|
|
||||||
setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSchemeThree && areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||||
|
setError('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||||
@@ -2332,10 +2357,17 @@ export default function HomePage() {
|
|||||||
setRestoredPointIds({});
|
setRestoredPointIds({});
|
||||||
setErasedPointIds({});
|
setErasedPointIds({});
|
||||||
setConfirmedCurveScatterByFan({});
|
setConfirmedCurveScatterByFan({});
|
||||||
|
setPerformanceTimings(null);
|
||||||
setProgress('正在准备标准化数据');
|
setProgress('正在准备标准化数据');
|
||||||
|
setProgressPercent(5);
|
||||||
|
setProgressStage(0);
|
||||||
|
|
||||||
try {
|
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({
|
const start = await startWindJob({
|
||||||
files: files.map((file) => ({
|
files: files.map((file) => ({
|
||||||
file_name: file.file_name,
|
file_name: file.file_name,
|
||||||
@@ -2346,25 +2378,29 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
jobId = start.job_id;
|
jobId = start.job_id;
|
||||||
|
|
||||||
const chunkCount = Math.ceil(rows.length / CHUNK_SIZE);
|
const uploadStartedAt = performance.now();
|
||||||
for (let index = 0; index < chunkCount; index += 1) {
|
setProgressStage(1);
|
||||||
const chunkRows = rows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
|
for (let index = 0; index < chunks.length; index += 1) {
|
||||||
setProgress(`正在上传数据分片 ${index + 1}/${chunkCount}`);
|
setProgress(`正在上传原始数据分片 ${index + 1}/${chunks.length}`);
|
||||||
|
setProgressPercent(15 + Math.round((index / chunks.length) * 55));
|
||||||
await uploadWindChunk({
|
await uploadWindChunk({
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
chunk_index: index,
|
chunk_index: index,
|
||||||
rows: chunkRows,
|
raw_rows: chunks[index],
|
||||||
raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE),
|
|
||||||
});
|
});
|
||||||
|
setProgressPercent(15 + Math.round(((index + 1) / chunks.length) * 55));
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress('正在清洗数据并计算功率曲线');
|
setProgress('正在清洗数据并计算功率曲线');
|
||||||
|
setProgressPercent(75);
|
||||||
|
setProgressStage(2);
|
||||||
const calculation = await finishWindJob({
|
const calculation = await finishWindJob({
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
options: {
|
options: {
|
||||||
curve_wind_speed_step: 0.5,
|
curve_wind_speed_step: 0.5,
|
||||||
scheme_id: selectedSchemeId,
|
scheme_id: selectedSchemeId,
|
||||||
...(!isSchemeTwo ? schemeOneValues : {}),
|
...(!isSchemeTwo && !isSchemeThree ? schemeOneValues : {}),
|
||||||
|
...(isSchemeThree ? schemeThreeValues : {}),
|
||||||
...(isSchemeTwo ? {
|
...(isSchemeTwo ? {
|
||||||
grid_connected_speed: gridConnectedSpeed,
|
grid_connected_speed: gridConnectedSpeed,
|
||||||
rated_generator_speed: ratedGeneratorSpeed,
|
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 });
|
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('计算完成');
|
setProgress('计算完成');
|
||||||
|
setProgressPercent(100);
|
||||||
|
setProgressStage(3);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
try {
|
try {
|
||||||
@@ -2390,6 +2434,8 @@ export default function HomePage() {
|
|||||||
setReportExportNotice(message);
|
setReportExportNotice(message);
|
||||||
}
|
}
|
||||||
setProgress('');
|
setProgress('');
|
||||||
|
setProgressPercent(0);
|
||||||
|
setProgressStage(-1);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -2399,10 +2445,14 @@ export default function HomePage() {
|
|||||||
<div className="home">
|
<div className="home">
|
||||||
<header className="homeHeader">
|
<header className="homeHeader">
|
||||||
<div>
|
<div>
|
||||||
<h1>风电功率计算平台</h1>
|
<h1>{activeSection === 'settings' ? '参数设置' : '风电功率计算'}</h1>
|
||||||
<p className="subtitle">多 Excel 导入、字段映射、数据清洗与功率曲线计算</p>
|
<p className="subtitle">
|
||||||
|
{activeSection === 'settings'
|
||||||
|
? '配置计算方案与设计功率曲线'
|
||||||
|
: '多 Excel 导入、字段映射、数据清洗与功率曲线计算'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="headerActions">
|
{activeSection === 'calculation' && <div className="headerActions">
|
||||||
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
|
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -2413,11 +2463,19 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
{reading ? '解析中' : '上传 Excel'}
|
{reading ? '解析中' : '上传 Excel'}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="alert error">{error}</div>}
|
{error && <div className="alert error">{error}</div>}
|
||||||
{progress && <div className="alert info">{progress}</div>}
|
{activeSection === 'calculation' && (
|
||||||
|
<section className="currentSchemeBanner" aria-label="当前使用的计算方案">
|
||||||
|
<div className="currentSchemeLabel">
|
||||||
|
<span>当前使用方案</span>
|
||||||
|
<strong>{selectedScheme?.name || '方案一'}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{schemeDescription || selectedScheme?.description || '暂无方案描述'}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{reportExportNotice && (
|
{reportExportNotice && (
|
||||||
<div className="dialogBackdrop" role="presentation">
|
<div className="dialogBackdrop" role="presentation">
|
||||||
<section className="appDialog" role="dialog" aria-modal="true" aria-labelledby="report-export-notice-title">
|
<section className="appDialog" role="dialog" aria-modal="true" aria-labelledby="report-export-notice-title">
|
||||||
@@ -2432,7 +2490,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="panel schemePanel">
|
<section className="panel schemePanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||||
<div className="panelHeader">
|
<div className="panelHeader">
|
||||||
<div>
|
<div>
|
||||||
<h2>计算方案</h2>
|
<h2>计算方案</h2>
|
||||||
@@ -2469,18 +2527,24 @@ export default function HomePage() {
|
|||||||
{!isSchemeTwo && (
|
{!isSchemeTwo && (
|
||||||
<>
|
<>
|
||||||
<p className="schemeParamHint">
|
<p className="schemeParamHint">
|
||||||
叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速
|
{isSchemeThree
|
||||||
|
? '叶尖速比 = 叶轮转速 × 3.14 × 叶轮半径 × 30 ÷ 风速'
|
||||||
|
: '叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速'}
|
||||||
</p>
|
</p>
|
||||||
<div className="schemeParamGrid">
|
<div className="schemeParamGrid">
|
||||||
{SCHEME_ONE_PARAM_FIELDS.map((field) => (
|
{(isSchemeThree ? SCHEME_THREE_PARAM_FIELDS : SCHEME_ONE_PARAM_FIELDS).map((field) => (
|
||||||
<label className="fieldControl" key={field.key}>
|
<label className="fieldControl" key={field.key}>
|
||||||
<span>{field.label}{field.unit ? ` (${field.unit})` : ''}</span>
|
<span>{field.label}{field.unit ? ` (${field.unit})` : ''}</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={field.min}
|
min={field.min}
|
||||||
step={field.step}
|
step={field.step}
|
||||||
value={schemeOneParams[field.key]}
|
value={(isSchemeThree ? schemeThreeParams : schemeOneParams)[field.key]}
|
||||||
onChange={(event) => handleSchemeOneParamChange(field.key, event.target.value)}
|
onChange={(event) => (
|
||||||
|
isSchemeThree
|
||||||
|
? handleSchemeThreeParamChange(field.key, event.target.value)
|
||||||
|
: handleSchemeOneParamChange(field.key, event.target.value)
|
||||||
|
)}
|
||||||
disabled={submitting || savingScheme}
|
disabled={submitting || savingScheme}
|
||||||
placeholder="请输入"
|
placeholder="请输入"
|
||||||
/>
|
/>
|
||||||
@@ -2567,7 +2631,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel designPowerPanel">
|
<section className="panel designPowerPanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||||
<div className="panelHeader">
|
<div className="panelHeader">
|
||||||
<div>
|
<div>
|
||||||
<h2>设计功率曲线</h2>
|
<h2>设计功率曲线</h2>
|
||||||
@@ -2650,7 +2714,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="workspace">
|
<section className="workspace" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panelHeader">
|
<div className="panelHeader">
|
||||||
<h2>1. 数据文件</h2>
|
<h2>1. 数据文件</h2>
|
||||||
@@ -2699,6 +2763,36 @@ export default function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{progress && (
|
||||||
|
<div className={`calculationProgress ${progressStage === 3 ? 'complete' : ''}`} role="status" aria-live="polite">
|
||||||
|
<div className="calculationProgressHeader">
|
||||||
|
<strong>计算进度</strong>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="calculationSteps" aria-label="计算步骤">
|
||||||
|
{CALCULATION_STEPS.map((step, index) => (
|
||||||
|
<div
|
||||||
|
className={`calculationStep ${index < progressStage ? 'completed' : ''} ${index === progressStage ? 'active' : ''}`}
|
||||||
|
key={step}
|
||||||
|
>
|
||||||
|
<i>{index < progressStage || progressStage === 3 ? '✓' : index + 1}</i>
|
||||||
|
<span>{step}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="calculationProgressTrack" aria-hidden="true">
|
||||||
|
<span style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<p>{progress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{performanceTimings && (
|
||||||
|
<div className="calculationTiming">
|
||||||
|
上传 {performanceTimings.upload ?? 0} ms · 服务端计算 {performanceTimings.calculation ?? 0} ms
|
||||||
|
(读取 {performanceTimings.read_parse ?? 0} ms,清洗 {performanceTimings.cleaning_and_points ?? 0} ms)
|
||||||
|
· 总计 {performanceTimings.total ?? 0} ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="actionRow">
|
<div className="actionRow">
|
||||||
<button
|
<button
|
||||||
className="primaryButton"
|
className="primaryButton"
|
||||||
@@ -2712,7 +2806,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||||
<div className="panelHeader">
|
<div className="panelHeader">
|
||||||
<h2>3. 列头预览</h2>
|
<h2>3. 列头预览</h2>
|
||||||
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
||||||
@@ -2729,7 +2823,7 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<section className="resultSection">
|
<section className="resultSection" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panelHeader">
|
<div className="panelHeader">
|
||||||
<h2>4. 清洗结果</h2>
|
<h2>4. 清洗结果</h2>
|
||||||
@@ -2758,6 +2852,7 @@ export default function HomePage() {
|
|||||||
<span>
|
<span>
|
||||||
曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点
|
曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点
|
||||||
· 滤除 {visibleFilteredPoints.length.toLocaleString()} 点
|
· 滤除 {visibleFilteredPoints.length.toLocaleString()} 点
|
||||||
|
{loadingFanPoints ? ' · 正在加载点数据…' : ''}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="secondaryButton"
|
className="secondaryButton"
|
||||||
@@ -2807,7 +2902,11 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
className="secondaryButton"
|
className="secondaryButton"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
|
onClick={() => exportValidRowsToExcel(
|
||||||
|
selectedFan,
|
||||||
|
effectiveScatter,
|
||||||
|
isSchemeThree,
|
||||||
|
)}
|
||||||
disabled={!effectiveScatter.length}
|
disabled={!effectiveScatter.length}
|
||||||
>
|
>
|
||||||
导出有效数据
|
导出有效数据
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { login } from '../utils/api';
|
||||||
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
|
// ─── Simplex-like 噪声(轻量实现) ─────────────────────────
|
||||||
|
function createNoise() {
|
||||||
|
const perm = new Uint8Array(512);
|
||||||
|
const p = new Uint8Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) p[i] = i;
|
||||||
|
for (let i = 255; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[p[i], p[j]] = [p[j], p[i]];
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||||||
|
|
||||||
|
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||||||
|
function lerp(a, b, t) { return a + t * (b - a); }
|
||||||
|
function grad(hash, x, y) {
|
||||||
|
const h = hash & 3;
|
||||||
|
const u = h < 2 ? x : y;
|
||||||
|
const v = h < 2 ? y : x;
|
||||||
|
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return function noise2D(x, y) {
|
||||||
|
const X = Math.floor(x) & 255;
|
||||||
|
const Y = Math.floor(y) & 255;
|
||||||
|
const xf = x - Math.floor(x);
|
||||||
|
const yf = y - Math.floor(y);
|
||||||
|
const u = fade(xf);
|
||||||
|
const v = fade(yf);
|
||||||
|
const a = perm[X] + Y;
|
||||||
|
const b = perm[X + 1] + Y;
|
||||||
|
return lerp(
|
||||||
|
lerp(grad(perm[a], xf, yf), grad(perm[b], xf - 1, yf), u),
|
||||||
|
lerp(grad(perm[a + 1], xf, yf - 1), grad(perm[b + 1], xf - 1, yf - 1), u),
|
||||||
|
v
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 宇宙深空 Canvas ──────────────────────────────────────
|
||||||
|
function DeepSpaceCanvas() {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const noise = createNoise();
|
||||||
|
let animId;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
// ── 三层星星 ──
|
||||||
|
const farStars = Array.from({ length: 500 }, () => ({
|
||||||
|
x: Math.random(), y: Math.random(),
|
||||||
|
r: 0.3 + Math.random() * 0.7,
|
||||||
|
alpha: 0.15 + Math.random() * 0.25,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
speed: 0.05 + Math.random() * 0.15,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const midStars = Array.from({ length: 120 }, () => ({
|
||||||
|
x: Math.random(), y: Math.random(),
|
||||||
|
r: 0.6 + Math.random() * 1.2,
|
||||||
|
alpha: 0.3 + Math.random() * 0.4,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
speed: 0.15 + Math.random() * 0.3,
|
||||||
|
hue: Math.random() > 0.7 ? 30 + Math.random() * 30 : 210 + Math.random() * 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nearStars = Array.from({ length: 35 }, () => ({
|
||||||
|
x: Math.random(), y: Math.random(),
|
||||||
|
r: 1.2 + Math.random() * 2,
|
||||||
|
alpha: 0.5 + Math.random() * 0.5,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
speed: 0.2 + Math.random() * 0.5,
|
||||||
|
hue: Math.random() > 0.6 ? 20 + Math.random() * 40 : 200 + Math.random() * 60,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 星尘带(密集微粒模拟银河带) ──
|
||||||
|
const starDust = Array.from({ length: 800 }, () => {
|
||||||
|
const band = 0.3 + Math.random() * 0.4; // 集中在中间带
|
||||||
|
return {
|
||||||
|
x: Math.random(),
|
||||||
|
y: band + (Math.random() - 0.5) * 0.25,
|
||||||
|
r: 0.2 + Math.random() * 0.5,
|
||||||
|
alpha: 0.06 + Math.random() * 0.12,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 流星 ──
|
||||||
|
let meteors = [];
|
||||||
|
function spawnMeteor() {
|
||||||
|
meteors.push({
|
||||||
|
x: Math.random() * 0.8 + 0.1,
|
||||||
|
y: Math.random() * 0.3,
|
||||||
|
vx: 0.003 + Math.random() * 0.004,
|
||||||
|
vy: 0.002 + Math.random() * 0.003,
|
||||||
|
life: 1,
|
||||||
|
len: 60 + Math.random() * 80,
|
||||||
|
hue: 200 + Math.random() * 40,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 星云离屏缓冲(低分辨率)
|
||||||
|
const nebulaCanvas = document.createElement('canvas');
|
||||||
|
const nebulaCtx = nebulaCanvas.getContext('2d');
|
||||||
|
const NEBULA_SCALE = 6;
|
||||||
|
let lastNebulaTime = -1;
|
||||||
|
|
||||||
|
function drawNebula(t) {
|
||||||
|
const nw = Math.ceil(canvas.width / NEBULA_SCALE);
|
||||||
|
const nh = Math.ceil(canvas.height / NEBULA_SCALE);
|
||||||
|
if (nebulaCanvas.width !== nw || nebulaCanvas.height !== nh) {
|
||||||
|
nebulaCanvas.width = nw;
|
||||||
|
nebulaCanvas.height = nh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每 3 帧更新一次星云(性能优化)
|
||||||
|
const frame = Math.floor(t * 10);
|
||||||
|
if (frame === lastNebulaTime) return;
|
||||||
|
lastNebulaTime = frame;
|
||||||
|
|
||||||
|
const imgData = nebulaCtx.createImageData(nw, nh);
|
||||||
|
const data = imgData.data;
|
||||||
|
const st = t * 0.02; // 极慢流动
|
||||||
|
|
||||||
|
for (let y = 0; y < nh; y++) {
|
||||||
|
for (let x = 0; x < nw; x++) {
|
||||||
|
const nx = x / nw * 4;
|
||||||
|
const ny = y / nh * 4;
|
||||||
|
|
||||||
|
// 多octave噪声叠加
|
||||||
|
let n = noise(nx + st, ny + st * 0.7) * 0.5
|
||||||
|
+ noise(nx * 2 + st * 0.5, ny * 2 - st * 0.3) * 0.3
|
||||||
|
+ noise(nx * 4 - st * 0.2, ny * 4 + st * 0.4) * 0.2;
|
||||||
|
n = (n + 1) * 0.5; // 归一化 0~1
|
||||||
|
|
||||||
|
const idx = (y * nw + x) * 4;
|
||||||
|
|
||||||
|
// 蓝紫星云
|
||||||
|
const b1 = Math.max(0, n - 0.4) * 2.5;
|
||||||
|
// 暗红星云(不同频率)
|
||||||
|
let n2 = noise(nx * 3 + 100 + st * 0.3, ny * 3 + 50 - st * 0.2);
|
||||||
|
n2 = (n2 + 1) * 0.5;
|
||||||
|
const r1 = Math.max(0, n2 - 0.5) * 2;
|
||||||
|
|
||||||
|
data[idx] = Math.floor(b1 * 30 + r1 * 45); // R
|
||||||
|
data[idx + 1] = Math.floor(b1 * 18 + r1 * 8); // G
|
||||||
|
data[idx + 2] = Math.floor(b1 * 80 + r1 * 20); // B
|
||||||
|
data[idx + 3] = Math.floor((b1 * 0.6 + r1 * 0.3) * 35); // A (非常淡)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nebulaCtx.putImageData(imgData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 穿梭星线 ──
|
||||||
|
const WARP_COUNT = 300;
|
||||||
|
const WARP_SPEED = 0.006;
|
||||||
|
const warpStars = Array.from({ length: WARP_COUNT }, () => ({
|
||||||
|
x: (Math.random() - 0.5) * 2,
|
||||||
|
y: (Math.random() - 0.5) * 2,
|
||||||
|
z: Math.random(),
|
||||||
|
pz: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let t = 0;
|
||||||
|
let meteorTimer = 0;
|
||||||
|
const DRIFT_SPEED = 0.00003; // 极慢整体漂移
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
t += 0.005;
|
||||||
|
meteorTimer += 1;
|
||||||
|
|
||||||
|
// 清屏(半透明 → 穿梭拖尾)
|
||||||
|
ctx.fillStyle = 'rgba(6, 6, 14, 0.18)';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// 整体漂移偏移
|
||||||
|
const driftX = Math.sin(t * 0.3) * w * DRIFT_SPEED * 100;
|
||||||
|
const driftY = Math.cos(t * 0.2) * h * DRIFT_SPEED * 100;
|
||||||
|
|
||||||
|
// ── 1. 星云 ──
|
||||||
|
drawNebula(t);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(nebulaCanvas, 0, 0, w, h);
|
||||||
|
|
||||||
|
// ── 2. 星尘带 ──
|
||||||
|
for (const d of starDust) {
|
||||||
|
const sx = ((d.x + driftX * 0.3 / w) % 1) * w;
|
||||||
|
const sy = d.y * h;
|
||||||
|
ctx.fillStyle = `rgba(180, 190, 220, ${d.alpha})`;
|
||||||
|
ctx.fillRect(sx, sy, d.r, d.r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 远景星 ──
|
||||||
|
for (const s of farStars) {
|
||||||
|
const alpha = s.alpha * (0.6 + 0.4 * Math.sin(t * s.speed + s.phase));
|
||||||
|
const sx = ((s.x + driftX * 0.2 / w) % 1) * w;
|
||||||
|
const sy = ((s.y + driftY * 0.2 / h) % 1) * h;
|
||||||
|
ctx.fillStyle = `rgba(200, 210, 240, ${alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 中景星 ──
|
||||||
|
for (const s of midStars) {
|
||||||
|
const alpha = s.alpha * (0.4 + 0.6 * Math.abs(Math.sin(t * s.speed + s.phase)));
|
||||||
|
const sx = ((s.x + driftX * 0.5 / w) % 1) * w;
|
||||||
|
const sy = ((s.y + driftY * 0.5 / h) % 1) * h;
|
||||||
|
|
||||||
|
// 柔和光晕
|
||||||
|
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, s.r * 3);
|
||||||
|
grad.addColorStop(0, `hsla(${s.hue}, 60%, 88%, ${alpha})`);
|
||||||
|
grad.addColorStop(0.4, `hsla(${s.hue}, 50%, 70%, ${alpha * 0.3})`);
|
||||||
|
grad.addColorStop(1, `hsla(${s.hue}, 40%, 50%, 0)`);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, s.r * 3, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, s.r * 0.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `hsla(${s.hue}, 50%, 95%, ${alpha * 0.8})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 前景星(大星 + 衍射芒) ──
|
||||||
|
for (const s of nearStars) {
|
||||||
|
const flicker = 0.3 + 0.7 * Math.abs(Math.sin(t * s.speed + s.phase));
|
||||||
|
const alpha = s.alpha * flicker;
|
||||||
|
const sx = ((s.x + driftX * 0.8 / w) % 1) * w;
|
||||||
|
const sy = ((s.y + driftY * 0.8 / h) % 1) * h;
|
||||||
|
const glow = s.r * (1 + 0.2 * Math.sin(t * s.speed * 1.5 + s.phase));
|
||||||
|
|
||||||
|
// 外层大光晕
|
||||||
|
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, glow * 6);
|
||||||
|
grad.addColorStop(0, `hsla(${s.hue}, 70%, 90%, ${alpha * 0.9})`);
|
||||||
|
grad.addColorStop(0.15, `hsla(${s.hue}, 60%, 80%, ${alpha * 0.4})`);
|
||||||
|
grad.addColorStop(0.5, `hsla(${s.hue}, 50%, 60%, ${alpha * 0.08})`);
|
||||||
|
grad.addColorStop(1, `hsla(${s.hue}, 40%, 50%, 0)`);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, glow * 6, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 核心
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, glow * 0.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `hsla(${s.hue}, 40%, 97%, ${alpha})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 十字衍射芒
|
||||||
|
const spikeLen = glow * 4 * flicker;
|
||||||
|
const spikeAlpha = alpha * 0.25;
|
||||||
|
ctx.strokeStyle = `hsla(${s.hue}, 50%, 90%, ${spikeAlpha})`;
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx - spikeLen, sy); ctx.lineTo(sx + spikeLen, sy); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx, sy - spikeLen); ctx.lineTo(sx, sy + spikeLen); ctx.stroke();
|
||||||
|
// 45° 短芒
|
||||||
|
const dLen = spikeLen * 0.4;
|
||||||
|
ctx.lineWidth = 0.4;
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx - dLen, sy - dLen); ctx.lineTo(sx + dLen, sy + dLen); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(sx + dLen, sy - dLen); ctx.lineTo(sx - dLen, sy + dLen); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 穿梭星线 ──
|
||||||
|
for (const star of warpStars) {
|
||||||
|
star.pz = star.z;
|
||||||
|
star.z -= WARP_SPEED;
|
||||||
|
if (star.z <= 0.001) {
|
||||||
|
star.x = (Math.random() - 0.5) * 2;
|
||||||
|
star.y = (Math.random() - 0.5) * 2;
|
||||||
|
star.z = 1; star.pz = 1;
|
||||||
|
}
|
||||||
|
const sx2 = (star.x / star.z) * cx + cx;
|
||||||
|
const sy2 = (star.y / star.z) * cy + cy;
|
||||||
|
const px = (star.x / star.pz) * cx + cx;
|
||||||
|
const py = (star.y / star.pz) * cy + cy;
|
||||||
|
const size = (1 - star.z) * 1.8;
|
||||||
|
const a = (1 - star.z) * 0.5;
|
||||||
|
const hue2 = 220 + (1 - star.z) * 60;
|
||||||
|
ctx.strokeStyle = `hsla(${hue2}, 70%, ${65 + (1 - star.z) * 25}%, ${a})`;
|
||||||
|
ctx.lineWidth = size;
|
||||||
|
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(sx2, sy2); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. 流星 ──
|
||||||
|
if (meteorTimer > 600 + Math.random() * 300) { // ~10-15秒
|
||||||
|
spawnMeteor();
|
||||||
|
meteorTimer = 0;
|
||||||
|
}
|
||||||
|
for (let i = meteors.length - 1; i >= 0; i--) {
|
||||||
|
const m = meteors[i];
|
||||||
|
m.x += m.vx;
|
||||||
|
m.y += m.vy;
|
||||||
|
m.life -= 0.012;
|
||||||
|
if (m.life <= 0) { meteors.splice(i, 1); continue; }
|
||||||
|
|
||||||
|
const mx = m.x * w;
|
||||||
|
const my = m.y * h;
|
||||||
|
const tailX = mx - m.vx * m.len * w;
|
||||||
|
const tailY = my - m.vy * m.len * h;
|
||||||
|
const grad = ctx.createLinearGradient(tailX, tailY, mx, my);
|
||||||
|
grad.addColorStop(0, `hsla(${m.hue}, 60%, 80%, 0)`);
|
||||||
|
grad.addColorStop(0.7, `hsla(${m.hue}, 70%, 90%, ${m.life * 0.4})`);
|
||||||
|
grad.addColorStop(1, `hsla(0, 0%, 100%, ${m.life * 0.8})`);
|
||||||
|
ctx.strokeStyle = grad;
|
||||||
|
ctx.lineWidth = 1.5 * m.life;
|
||||||
|
ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(mx, my); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className={styles.starfield} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 浮动粒子层 ────────────────────────────────────────────
|
||||||
|
function FloatingParticles() {
|
||||||
|
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
left: Math.random() * 100,
|
||||||
|
delay: Math.random() * 8,
|
||||||
|
duration: 6 + Math.random() * 10,
|
||||||
|
size: 2 + Math.random() * 3,
|
||||||
|
opacity: 0.15 + Math.random() * 0.3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.particlesLayer}>
|
||||||
|
{particles.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={styles.particle}
|
||||||
|
style={{
|
||||||
|
left: `${p.left}%`,
|
||||||
|
width: p.size,
|
||||||
|
height: p.size,
|
||||||
|
opacity: p.opacity,
|
||||||
|
animationDelay: `${p.delay}s`,
|
||||||
|
animationDuration: `${p.duration}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 登录页 ────────────────────────────────────────────────
|
||||||
|
export default function LoginPage({ initialMessage = '', onLogin }) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState(initialMessage);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(initialMessage);
|
||||||
|
}, [initialMessage]);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username || !password) {
|
||||||
|
setError('请填写用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
onLogin(await login(username.trim(), password));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<DeepSpaceCanvas />
|
||||||
|
<FloatingParticles />
|
||||||
|
<div className={styles.radialGlow} />
|
||||||
|
|
||||||
|
<form className={styles.card} onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.cardGlow} />
|
||||||
|
<img src="/logo-1.svg" alt="风电功率计算平台" className={styles.logo} />
|
||||||
|
<h1 className={styles.title}>风电功率计算平台</h1>
|
||||||
|
<p className={styles.subtitle}>数据分析 · 功率计算</p>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>用户名</label>
|
||||||
|
<input
|
||||||
|
id="login-username"
|
||||||
|
className={styles.input}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>密码</label>
|
||||||
|
<div className={styles.inputWrap}>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
className={styles.input}
|
||||||
|
type={showPwd ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.eyeBtn}
|
||||||
|
onClick={() => setShowPwd(!showPwd)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||||
|
>
|
||||||
|
{showPwd ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||||
|
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||||
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.error} role="alert">{error}</div>}
|
||||||
|
|
||||||
|
<button id="login-submit" className={styles.button} type="submit" disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登 录'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<span className={styles.footerDot} />
|
||||||
|
<span className={styles.footerText}>Wind Power Analysis</span>
|
||||||
|
<span className={styles.footerDot} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ async function request(url, options = {}) {
|
|||||||
|
|
||||||
const resp = await fetch(`${BASE_URL}${url}`, {
|
const resp = await fetch(`${BASE_URL}${url}`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -33,6 +34,11 @@ async function request(url, options = {}) {
|
|||||||
|
|
||||||
// 业务失败
|
// 业务失败
|
||||||
if (json.status !== 0) {
|
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 || '请求失败');
|
throw new Error(json.msg || '请求失败');
|
||||||
}
|
}
|
||||||
return json.data;
|
return json.data;
|
||||||
@@ -54,6 +60,39 @@ export function getHealth() {
|
|||||||
return request('/system/health');
|
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<object>}>}
|
* @returns {Promise<{default_scheme_id: string, schemes: Array<object>}>}
|
||||||
@@ -108,8 +147,8 @@ export function startWindJob(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传标准化后的数据分片
|
* 上传完整原始数据分片,服务端按任务列映射生成计算行。
|
||||||
* @param {{job_id: string, chunk_index: number, rows: Array<object>}} payload
|
* @param {{job_id: string, chunk_index: number, raw_rows: Array<object>}} payload
|
||||||
* @returns {Promise<{accepted_rows: number}>}
|
* @returns {Promise<{accepted_rows: number}>}
|
||||||
*/
|
*/
|
||||||
export function uploadWindChunk(payload) {
|
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
|
* @param {string} jobId
|
||||||
@@ -147,6 +191,7 @@ export async function downloadWindReport(jobId, payload) {
|
|||||||
const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, {
|
const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -154,11 +199,19 @@ export async function downloadWindReport(jobId, payload) {
|
|||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(text);
|
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 || '完整报告生成失败');
|
throw new Error(json.msg || '完整报告生成失败');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试');
|
if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp.blob();
|
return {
|
||||||
|
blob: await resp.blob(),
|
||||||
|
serverTiming: resp.headers.get('Server-Timing') || '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -101,6 +101,17 @@ export function getDrawableActualCurve(actualCurve = []) {
|
|||||||
.sort((left, right) => left.wind_speed - right.wind_speed);
|
.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) {
|
function interpolatePowerAtWindSpeed(designCurve, windSpeed) {
|
||||||
if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON
|
if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON
|
||||||
|| windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) {
|
|| windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('getDrawableActualCurve skips empty design bins and keeps adjacent valid points', () => {
|
||||||
const actualCurve = buildActualCurveFromDesign(
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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, '');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user