功能: 完善账号、工作台与功率计算方案
- 新增账号管理、有效期、启停控制与登录工作台布局 - 新增方案三叶轮转速计算及字段映射和报告支持 - 修复功率曲线断线、图例越界及方案一散点绘制 - 优化计算进度、浅色主题和当前方案提示 - 补充前端测试与部署依赖配置
This commit is contained in:
@@ -35,6 +35,8 @@ endif()
|
||||
|
||||
find_package(Drogon CONFIG REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
find_path(SQLITE3_INCLUDE_DIR sqlite3.h REQUIRED)
|
||||
find_library(SQLITE3_LIBRARY sqlite3 REQUIRED)
|
||||
|
||||
# libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。
|
||||
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
@@ -56,10 +58,12 @@ add_executable(wind_server ${SOURCES})
|
||||
target_include_directories(wind_server PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${REPO_ROOT}/third_party
|
||||
${SQLITE3_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(wind_server PRIVATE
|
||||
Drogon::Drogon
|
||||
${SQLITE3_LIBRARY}
|
||||
Threads::Threads
|
||||
xlsxwriter
|
||||
)
|
||||
|
||||
@@ -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 <fstream>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <xlsxwriter/format.h>
|
||||
@@ -36,11 +38,15 @@ constexpr auto kUploadIdleTimeout = std::chrono::minutes(30);
|
||||
constexpr auto kCompletedJobRetention = std::chrono::hours(24);
|
||||
constexpr const char* kDefaultSchemeId = "scheme_one";
|
||||
constexpr const char* kSchemeTwoId = "scheme_two";
|
||||
constexpr const char* kSchemeThreeId = "scheme_three";
|
||||
constexpr const char* kSchemeOneName = "方案一";
|
||||
constexpr const char* kSchemeTwoName = "方案二";
|
||||
constexpr const char* kSchemeThreeName = "方案三";
|
||||
constexpr const char* kSchemeOneDefaultDescription = "通用方案";
|
||||
constexpr const char* kSchemeTwoDefaultDescription =
|
||||
"桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数";
|
||||
constexpr const char* kSchemeThreeDefaultDescription =
|
||||
"叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比";
|
||||
|
||||
struct RawRow {
|
||||
std::string time;
|
||||
@@ -48,6 +54,7 @@ struct RawRow {
|
||||
double wind_speed = 0.0;
|
||||
double active_power = 0.0;
|
||||
double generator_speed = 0.0;
|
||||
double rotor_speed = 0.0;
|
||||
};
|
||||
|
||||
struct ValidRow {
|
||||
@@ -58,6 +65,7 @@ struct ValidRow {
|
||||
double wind_speed = 0.0;
|
||||
double active_power = 0.0;
|
||||
double generator_speed = 0.0;
|
||||
double rotor_speed = 0.0;
|
||||
double blade_pitch_1 = 0.0;
|
||||
double blade_pitch_2 = 0.0;
|
||||
double blade_pitch_3 = 0.0;
|
||||
@@ -125,6 +133,17 @@ struct SchemeInfo {
|
||||
double rated_generator_speed = 1755.0;
|
||||
double rated_power = 2000.0;
|
||||
double scheme_two_report_wind_speed_interval = 0.25;
|
||||
double scheme_three_rated_power = 4800.0;
|
||||
double scheme_three_rated_wind_speed = 14.0;
|
||||
double scheme_three_power_step = 5.0;
|
||||
double scheme_three_cleaning_wind_speed_step = 0.25;
|
||||
double scheme_three_wind_speed_change_threshold = 1.0;
|
||||
double scheme_three_iqr_lower_multiplier = 1.2;
|
||||
double scheme_three_iqr_upper_multiplier = 2.0;
|
||||
double scheme_three_minimum_generator_speed = 1.0;
|
||||
double scheme_three_generator_speed_k = 0.9;
|
||||
double scheme_three_rotor_radius = 78.0;
|
||||
double scheme_three_report_wind_speed_interval = 0.25;
|
||||
};
|
||||
|
||||
constexpr double kRatedCornerWindBefore = 0.5;
|
||||
@@ -170,6 +189,14 @@ fs::path JobResultPath(const std::string& job_id) {
|
||||
return JobDir(job_id) / "result.json";
|
||||
}
|
||||
|
||||
fs::path JobPointsDir(const std::string& job_id) {
|
||||
return JobDir(job_id) / "points";
|
||||
}
|
||||
|
||||
fs::path JobFanPointsPath(const std::string& job_id, size_t fan_index) {
|
||||
return JobPointsDir(job_id) / ("fan_" + std::to_string(fan_index) + ".json");
|
||||
}
|
||||
|
||||
std::mutex g_task_mutex;
|
||||
std::string g_active_job_id;
|
||||
std::chrono::steady_clock::time_point g_active_since;
|
||||
@@ -279,6 +306,28 @@ std::string FileNameForFan(const std::string& fan_id) {
|
||||
return output.empty() ? "wind_turbine" : output;
|
||||
}
|
||||
|
||||
std::string PointIdFor(const json& point, const std::string& fan_id) {
|
||||
const auto text = [&point](const char* field) {
|
||||
return point.contains(field) ? JsonText(point[field]) : "";
|
||||
};
|
||||
const auto point_fan_id = text("fan_id");
|
||||
return (point_fan_id.empty() ? fan_id : point_fan_id) + "|" + text("time") + "|" +
|
||||
text("wind_speed") + "|" + text("active_power") + "|" + text("reason");
|
||||
}
|
||||
|
||||
std::unordered_set<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() {
|
||||
return fs::path("data") / "wind_schemes.json";
|
||||
}
|
||||
@@ -485,7 +534,13 @@ bool SaveChartOptionsToFile(const json& options) {
|
||||
}
|
||||
|
||||
std::string NormalizeSchemeId(const std::string& scheme_id) {
|
||||
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
|
||||
if (scheme_id == kSchemeTwoId) {
|
||||
return kSchemeTwoId;
|
||||
}
|
||||
if (scheme_id == kSchemeThreeId) {
|
||||
return kSchemeThreeId;
|
||||
}
|
||||
return kDefaultSchemeId;
|
||||
}
|
||||
|
||||
std::vector<SchemeInfo> DefaultSchemes() {
|
||||
@@ -498,7 +553,12 @@ std::vector<SchemeInfo> DefaultSchemes() {
|
||||
scheme_two.id = kSchemeTwoId;
|
||||
scheme_two.name = kSchemeTwoName;
|
||||
scheme_two.description = kSchemeTwoDefaultDescription;
|
||||
return {scheme_one, scheme_two};
|
||||
|
||||
SchemeInfo scheme_three;
|
||||
scheme_three.id = kSchemeThreeId;
|
||||
scheme_three.name = kSchemeThreeName;
|
||||
scheme_three.description = kSchemeThreeDefaultDescription;
|
||||
return {scheme_one, scheme_two, scheme_three};
|
||||
}
|
||||
|
||||
std::optional<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"]["report_wind_speed_interval"] =
|
||||
scheme.scheme_two_report_wind_speed_interval;
|
||||
} else if (scheme.id == kSchemeThreeId) {
|
||||
data["parameters"] = {
|
||||
{"rated_power", scheme.scheme_three_rated_power},
|
||||
{"rated_wind_speed", scheme.scheme_three_rated_wind_speed},
|
||||
{"power_step", scheme.scheme_three_power_step},
|
||||
{"cleaning_wind_speed_step", scheme.scheme_three_cleaning_wind_speed_step},
|
||||
{"wind_speed_change_threshold", scheme.scheme_three_wind_speed_change_threshold},
|
||||
{"iqr_lower_multiplier", scheme.scheme_three_iqr_lower_multiplier},
|
||||
{"iqr_upper_multiplier", scheme.scheme_three_iqr_upper_multiplier},
|
||||
{"minimum_generator_speed", scheme.scheme_three_minimum_generator_speed},
|
||||
{"generator_speed_k", scheme.scheme_three_generator_speed_k},
|
||||
{"rotor_radius", scheme.scheme_three_rotor_radius},
|
||||
{"report_wind_speed_interval", scheme.scheme_three_report_wind_speed_interval},
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -615,6 +689,40 @@ std::vector<SchemeInfo> LoadSchemes() {
|
||||
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
|
||||
scheme.scheme_two_report_wind_speed_interval = value.value();
|
||||
}
|
||||
} else if (scheme.id == kSchemeThreeId) {
|
||||
const auto load_positive = [&](const std::string& field, double& target) {
|
||||
if (const auto value = GetNumberField(params, field);
|
||||
value.has_value() && std::isfinite(value.value()) &&
|
||||
value.value() > 0.0) {
|
||||
target = value.value();
|
||||
}
|
||||
};
|
||||
const auto load_non_negative = [&](const std::string& field,
|
||||
double& target) {
|
||||
if (const auto value = GetNumberField(params, field);
|
||||
value.has_value() && std::isfinite(value.value()) &&
|
||||
value.value() >= 0.0) {
|
||||
target = value.value();
|
||||
}
|
||||
};
|
||||
load_positive("rated_power", scheme.scheme_three_rated_power);
|
||||
load_positive("rated_wind_speed", scheme.scheme_three_rated_wind_speed);
|
||||
load_positive("power_step", scheme.scheme_three_power_step);
|
||||
load_positive("cleaning_wind_speed_step",
|
||||
scheme.scheme_three_cleaning_wind_speed_step);
|
||||
load_non_negative("wind_speed_change_threshold",
|
||||
scheme.scheme_three_wind_speed_change_threshold);
|
||||
load_non_negative("iqr_lower_multiplier",
|
||||
scheme.scheme_three_iqr_lower_multiplier);
|
||||
load_non_negative("iqr_upper_multiplier",
|
||||
scheme.scheme_three_iqr_upper_multiplier);
|
||||
load_non_negative("minimum_generator_speed",
|
||||
scheme.scheme_three_minimum_generator_speed);
|
||||
load_non_negative("generator_speed_k",
|
||||
scheme.scheme_three_generator_speed_k);
|
||||
load_positive("rotor_radius", scheme.scheme_three_rotor_radius);
|
||||
load_positive("report_wind_speed_interval",
|
||||
scheme.scheme_three_report_wind_speed_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,6 +821,53 @@ std::optional<std::time_t> ParseTime(std::string value) {
|
||||
return std::mktime(&tm);
|
||||
}
|
||||
|
||||
std::string NormalizeRawTime(const json& value) {
|
||||
if (value.is_number()) {
|
||||
// Excel serial dates use 1899-12-30 as the practical epoch.
|
||||
const auto seconds = static_cast<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 data = json::object();
|
||||
for (const auto& item : counters) {
|
||||
@@ -851,6 +1006,10 @@ bool IsSchemeTwo(const CalculationOptions& options) {
|
||||
return options.scheme_id == kSchemeTwoId;
|
||||
}
|
||||
|
||||
bool IsSchemeThree(const CalculationOptions& options) {
|
||||
return options.scheme_id == kSchemeThreeId;
|
||||
}
|
||||
|
||||
void AddInvalid(std::unordered_map<std::string, int>& counters, const std::string& reason) {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
result.reserve(rows.size());
|
||||
for (double interval = min_wind; interval <= max_wind;
|
||||
interval += options.cleaning_wind_speed_step) {
|
||||
std::vector<ValidRow> interval_rows;
|
||||
for (const auto& item : buckets) {
|
||||
const auto& interval_rows = item.second;
|
||||
std::vector<double> values;
|
||||
for (const auto& row : rows) {
|
||||
if (row.wind_speed >= interval &&
|
||||
row.wind_speed < interval + options.cleaning_wind_speed_step) {
|
||||
interval_rows.push_back(row);
|
||||
values.push_back(value_getter(row));
|
||||
}
|
||||
values.reserve(interval_rows.size());
|
||||
for (const auto& row : interval_rows) {
|
||||
values.push_back(value_getter(row));
|
||||
}
|
||||
|
||||
if (interval_rows.empty()) {
|
||||
@@ -1074,18 +1236,22 @@ std::vector<CurveBin> BuildMedianCurveBins(const std::vector<ValidRow>& rows,
|
||||
|
||||
const double curve_min = 1.0 - wind_speed_step * 0.5;
|
||||
const double curve_max = 25.0 + wind_speed_step * 0.5;
|
||||
std::vector<CurveBin> bins;
|
||||
for (double start = curve_min; start < curve_max; start += wind_speed_step) {
|
||||
const double end = start + wind_speed_step;
|
||||
std::vector<double> values;
|
||||
for (const auto& row : rows) {
|
||||
if (row.wind_speed > start && row.wind_speed <= end) {
|
||||
values.push_back(row.active_power);
|
||||
}
|
||||
const auto bucket_count = static_cast<size_t>(std::ceil((curve_max - curve_min) / wind_speed_step));
|
||||
std::vector<std::vector<double>> values_by_bucket(bucket_count);
|
||||
for (const auto& row : rows) {
|
||||
const auto bucket = static_cast<long long>(std::ceil((row.wind_speed - curve_min) /
|
||||
wind_speed_step) - 1.0);
|
||||
if (bucket >= 0 && static_cast<size_t>(bucket) < values_by_bucket.size()) {
|
||||
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()) {
|
||||
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.sample_count = values.size();
|
||||
bins.push_back(bin);
|
||||
@@ -1371,14 +1537,14 @@ void WindPowerController::SaveSchemeDescription(
|
||||
return;
|
||||
}
|
||||
|
||||
std::optional<json> scheme_one_parameters;
|
||||
std::optional<json> scheme_base_parameters;
|
||||
std::optional<double> grid_connected_speed;
|
||||
std::optional<double> rated_generator_speed;
|
||||
std::optional<double> rated_power;
|
||||
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()) {
|
||||
SendError(callback, kErrorInvalidRequest, "方案一参数格式错误");
|
||||
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
||||
return;
|
||||
}
|
||||
const auto& params = body.value()["parameters"];
|
||||
@@ -1392,24 +1558,25 @@ void WindPowerController::SaveSchemeDescription(
|
||||
};
|
||||
if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") ||
|
||||
!valid_positive("power_step") || !valid_positive("cleaning_wind_speed_step") ||
|
||||
!valid_positive("rotor_radius") || !valid_positive("gearbox_ratio") ||
|
||||
!valid_positive("rotor_radius") ||
|
||||
(normalized_id == kDefaultSchemeId && !valid_positive("gearbox_ratio")) ||
|
||||
!valid_positive("report_wind_speed_interval") ||
|
||||
!valid_non_negative("wind_speed_change_threshold") ||
|
||||
!valid_non_negative("iqr_lower_multiplier") ||
|
||||
!valid_non_negative("iqr_upper_multiplier") ||
|
||||
!valid_non_negative("minimum_generator_speed") ||
|
||||
!valid_non_negative("generator_speed_k")) {
|
||||
SendError(callback, kErrorInvalidRequest, "方案一参数必须为合法数值");
|
||||
SendError(callback, kErrorInvalidRequest, "方案参数必须为合法数值");
|
||||
return;
|
||||
}
|
||||
if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 ||
|
||||
GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 ||
|
||||
GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 ||
|
||||
GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) {
|
||||
SendError(callback, kErrorInvalidRequest, "方案一参数超出允许范围");
|
||||
SendError(callback, kErrorInvalidRequest, "方案参数超出允许范围");
|
||||
return;
|
||||
}
|
||||
scheme_one_parameters = params;
|
||||
scheme_base_parameters = params;
|
||||
} else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) {
|
||||
if (!body.value()["parameters"].is_object()) {
|
||||
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
|
||||
@@ -1436,7 +1603,7 @@ void WindPowerController::SaveSchemeDescription(
|
||||
if (scheme.id == normalized_id) {
|
||||
scheme.description = description.value();
|
||||
if (scheme.id == kDefaultSchemeId) {
|
||||
const auto& params = scheme_one_parameters.value();
|
||||
const auto& params = scheme_base_parameters.value();
|
||||
scheme.scheme_one_rated_power = GetDoubleField(params, "rated_power").value();
|
||||
scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value();
|
||||
scheme.scheme_one_power_step = GetDoubleField(params, "power_step").value();
|
||||
@@ -1470,6 +1637,30 @@ void WindPowerController::SaveSchemeDescription(
|
||||
scheme.scheme_two_report_wind_speed_interval =
|
||||
report_wind_speed_interval.value();
|
||||
}
|
||||
} else if (scheme.id == kSchemeThreeId) {
|
||||
const auto& params = scheme_base_parameters.value();
|
||||
scheme.scheme_three_rated_power =
|
||||
GetDoubleField(params, "rated_power").value();
|
||||
scheme.scheme_three_rated_wind_speed =
|
||||
GetDoubleField(params, "rated_wind_speed").value();
|
||||
scheme.scheme_three_power_step =
|
||||
GetDoubleField(params, "power_step").value();
|
||||
scheme.scheme_three_cleaning_wind_speed_step =
|
||||
GetDoubleField(params, "cleaning_wind_speed_step").value();
|
||||
scheme.scheme_three_wind_speed_change_threshold =
|
||||
GetDoubleField(params, "wind_speed_change_threshold").value();
|
||||
scheme.scheme_three_iqr_lower_multiplier =
|
||||
GetDoubleField(params, "iqr_lower_multiplier").value();
|
||||
scheme.scheme_three_iqr_upper_multiplier =
|
||||
GetDoubleField(params, "iqr_upper_multiplier").value();
|
||||
scheme.scheme_three_minimum_generator_speed =
|
||||
GetDoubleField(params, "minimum_generator_speed").value();
|
||||
scheme.scheme_three_generator_speed_k =
|
||||
GetDoubleField(params, "generator_speed_k").value();
|
||||
scheme.scheme_three_rotor_radius =
|
||||
GetDoubleField(params, "rotor_radius").value();
|
||||
scheme.scheme_three_report_wind_speed_interval =
|
||||
GetDoubleField(params, "report_wind_speed_interval").value();
|
||||
}
|
||||
updated = true;
|
||||
break;
|
||||
@@ -1588,8 +1779,8 @@ void WindPowerController::UploadChunk(
|
||||
SendError(callback, kErrorJobNotFound, "计算任务不存在");
|
||||
return;
|
||||
}
|
||||
if (!body->contains("rows") || !(*body)["rows"].is_array()) {
|
||||
SendError(callback, kErrorInvalidRequest, "缺少 rows 参数");
|
||||
if (!body->contains("raw_rows") || !(*body)["raw_rows"].is_array()) {
|
||||
SendError(callback, kErrorInvalidRequest, "缺少 raw_rows 参数");
|
||||
return;
|
||||
}
|
||||
if (IsTaskBusyFor(job_id.value())) {
|
||||
@@ -1597,22 +1788,26 @@ void WindPowerController::UploadChunk(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
json metadata;
|
||||
{
|
||||
std::ifstream meta(JobDir(job_id.value()) / "metadata.json");
|
||||
meta >> metadata;
|
||||
}
|
||||
std::ofstream out(JobRowsPath(job_id.value()), std::ios::app);
|
||||
std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app);
|
||||
int accepted = 0;
|
||||
for (const auto& row : (*body)["rows"]) {
|
||||
if (!row.is_object()) {
|
||||
for (const auto& raw_row : (*body)["raw_rows"]) {
|
||||
if (!raw_row.is_object()) {
|
||||
continue;
|
||||
}
|
||||
out << row.dump() << '\n';
|
||||
++accepted;
|
||||
}
|
||||
out.close();
|
||||
if (body->contains("raw_rows") && (*body)["raw_rows"].is_array()) {
|
||||
for (const auto& row : (*body)["raw_rows"]) {
|
||||
if (row.is_object()) raw_out << row.dump() << '\n';
|
||||
raw_out << raw_row.dump() << '\n';
|
||||
const auto row = BuildStandardRow(raw_row, metadata);
|
||||
if (row.has_value()) {
|
||||
out << row->dump() << '\n';
|
||||
++accepted;
|
||||
}
|
||||
}
|
||||
out.close();
|
||||
raw_out.close();
|
||||
TouchTask(job_id.value());
|
||||
|
||||
@@ -1645,6 +1840,7 @@ void WindPowerController::FinishJob(
|
||||
return;
|
||||
}
|
||||
TaskReleaseGuard finish_guard(job_id.value());
|
||||
const auto calculation_started = std::chrono::steady_clock::now();
|
||||
|
||||
const CalculationOptions options = ParseOptions(*body);
|
||||
if (body->contains("options") && (*body)["options"].is_object() &&
|
||||
@@ -1668,6 +1864,7 @@ void WindPowerController::FinishJob(
|
||||
std::vector<ValidRow> parsed_rows;
|
||||
std::unordered_map<std::string, int> invalid_reasons;
|
||||
int raw_rows = 0;
|
||||
const auto read_started = std::chrono::steady_clock::now();
|
||||
|
||||
try {
|
||||
std::ifstream in(JobRowsPath(job_id.value()));
|
||||
@@ -1691,6 +1888,7 @@ void WindPowerController::FinishJob(
|
||||
const auto wind_speed = GetDoubleField(row, "wind_speed");
|
||||
const auto active_power = GetDoubleField(row, "active_power");
|
||||
const auto generator_speed = GetDoubleField(row, "generator_speed");
|
||||
const auto rotor_speed = GetDoubleField(row, "rotor_speed");
|
||||
const auto blade_pitch_1 = GetDoubleField(row, "blade_pitch_1");
|
||||
const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2");
|
||||
const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3");
|
||||
@@ -1706,6 +1904,12 @@ void WindPowerController::FinishJob(
|
||||
AddInvalid(invalid_reasons, "invalid_generator_speed");
|
||||
continue;
|
||||
}
|
||||
if (IsSchemeThree(options) &&
|
||||
(!rotor_speed.has_value() || !std::isfinite(rotor_speed.value()) ||
|
||||
rotor_speed.value() <= 0.0)) {
|
||||
AddInvalid(invalid_reasons, "invalid_rotor_speed");
|
||||
continue;
|
||||
}
|
||||
if (IsSchemeTwo(options) &&
|
||||
(!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) ||
|
||||
!blade_pitch_2.has_value() || !std::isfinite(blade_pitch_2.value()) ||
|
||||
@@ -1740,6 +1944,9 @@ void WindPowerController::FinishJob(
|
||||
valid_row.wind_speed = wind_speed.value();
|
||||
valid_row.active_power = active_power.value();
|
||||
valid_row.generator_speed = generator_speed.value();
|
||||
if (rotor_speed.has_value() && std::isfinite(rotor_speed.value())) {
|
||||
valid_row.rotor_speed = rotor_speed.value();
|
||||
}
|
||||
if (IsSchemeTwo(options)) {
|
||||
valid_row.blade_pitch_1 = blade_pitch_1.value();
|
||||
valid_row.blade_pitch_2 = blade_pitch_2.value();
|
||||
@@ -1755,6 +1962,8 @@ void WindPowerController::FinishJob(
|
||||
SendError(callback, kErrorServer, "读取任务数据失败");
|
||||
return;
|
||||
}
|
||||
const auto read_parse_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - read_started).count();
|
||||
|
||||
std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) {
|
||||
if (left.fan_id != right.fan_id) {
|
||||
@@ -1787,8 +1996,7 @@ void WindPowerController::FinishJob(
|
||||
json fans = json::array();
|
||||
json curves = json::object();
|
||||
json bins = json::object();
|
||||
json scatter_points = json::object();
|
||||
json filtered_points = json::object();
|
||||
json point_files = json::object();
|
||||
json estimated_params = json::object();
|
||||
|
||||
std::vector<std::string> fan_ids;
|
||||
@@ -1809,7 +2017,10 @@ void WindPowerController::FinishJob(
|
||||
int scheme_two_low_speed_pitch_count = 0;
|
||||
int scheme_two_low_power_pitch_count = 0;
|
||||
int cleaned_rows_count = 0;
|
||||
for (const auto& fan_id : fan_ids) {
|
||||
const auto cleaning_started = std::chrono::steady_clock::now();
|
||||
fs::create_directories(JobPointsDir(job_id.value()));
|
||||
for (size_t fan_index = 0; fan_index < fan_ids.size(); ++fan_index) {
|
||||
const auto& fan_id = fan_ids[fan_index];
|
||||
fans.push_back(fan_id);
|
||||
auto fan_rows = rows_by_fan[fan_id];
|
||||
std::vector<RemovedPoint> fan_removed_points;
|
||||
@@ -1836,9 +2047,11 @@ void WindPowerController::FinishJob(
|
||||
limit_power_count += removed;
|
||||
|
||||
for (auto& row : fan_rows) {
|
||||
row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio *
|
||||
options.rotor_radius * 30.0 /
|
||||
row.wind_speed;
|
||||
const double speed = IsSchemeThree(options)
|
||||
? row.rotor_speed
|
||||
: row.generator_speed * options.gearbox_ratio;
|
||||
row.tip_speed_ratio = speed * 3.14 * options.rotor_radius * 30.0 /
|
||||
row.wind_speed;
|
||||
}
|
||||
|
||||
fan_rows = FilterByWindBinIqr(
|
||||
@@ -1913,11 +2126,10 @@ void WindPowerController::FinishJob(
|
||||
point["wind_speed"] = row.wind_speed;
|
||||
point["active_power"] = row.active_power;
|
||||
point["generator_speed"] = row.generator_speed;
|
||||
point["rotor_speed"] = row.rotor_speed;
|
||||
point["pitch_angle_average"] = row.pitch_angle_average;
|
||||
fan_scatter.push_back(point);
|
||||
}
|
||||
scatter_points[fan_id] = fan_scatter;
|
||||
|
||||
json fan_filtered = json::array();
|
||||
for (const auto& removed_point : fan_removed_points) {
|
||||
json point;
|
||||
@@ -1926,11 +2138,19 @@ void WindPowerController::FinishJob(
|
||||
point["wind_speed"] = removed_point.row.wind_speed;
|
||||
point["active_power"] = removed_point.row.active_power;
|
||||
point["generator_speed"] = removed_point.row.generator_speed;
|
||||
point["rotor_speed"] = removed_point.row.rotor_speed;
|
||||
point["pitch_angle_average"] = removed_point.row.pitch_angle_average;
|
||||
point["reason"] = removed_point.reason;
|
||||
fan_filtered.push_back(point);
|
||||
}
|
||||
filtered_points[fan_id] = fan_filtered;
|
||||
json point_data;
|
||||
point_data["fan_id"] = fan_id;
|
||||
point_data["scatter_points"] = std::move(fan_scatter);
|
||||
point_data["filtered_points"] = std::move(fan_filtered);
|
||||
const auto point_path = JobFanPointsPath(job_id.value(), fan_index);
|
||||
std::ofstream point_out(point_path, std::ios::trunc);
|
||||
point_out << point_data.dump();
|
||||
point_files[fan_id] = point_path.filename().string();
|
||||
|
||||
json fan_curve = json::array();
|
||||
json fan_bins_json = json::array();
|
||||
@@ -2008,9 +2228,14 @@ void WindPowerController::FinishJob(
|
||||
data["fans"] = fans;
|
||||
data["curves"] = curves;
|
||||
data["bins"] = bins;
|
||||
data["scatter_points"] = scatter_points;
|
||||
data["filtered_points"] = filtered_points;
|
||||
data["point_files"] = point_files;
|
||||
data["estimated_params"] = estimated_params;
|
||||
data["timings_ms"]["read_parse"] = read_parse_ms;
|
||||
data["timings_ms"]["cleaning_and_points"] = std::chrono::duration_cast<std::chrono::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 {
|
||||
std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc);
|
||||
@@ -2020,7 +2245,49 @@ void WindPowerController::FinishJob(
|
||||
return;
|
||||
}
|
||||
|
||||
SendSuccess(callback, data);
|
||||
json response_data = data;
|
||||
if (!fan_ids.empty()) {
|
||||
std::ifstream point_input(JobFanPointsPath(job_id.value(), 0));
|
||||
json initial_points;
|
||||
point_input >> initial_points;
|
||||
response_data["initial_fan_id"] = fan_ids.front();
|
||||
response_data["scatter_points"] = json::object({{fan_ids.front(), initial_points["scatter_points"]}});
|
||||
response_data["filtered_points"] = json::object({{fan_ids.front(), initial_points["filtered_points"]}});
|
||||
}
|
||||
LOG_INFO << "wind job " << job_id.value() << " completed in "
|
||||
<< calculation_ms << " ms";
|
||||
SendSuccess(callback, response_data);
|
||||
}
|
||||
|
||||
void WindPowerController::GetFanPoints(
|
||||
const HttpRequestPtr&,
|
||||
std::function<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(
|
||||
@@ -2038,8 +2305,7 @@ void WindPowerController::ExportReport(
|
||||
return;
|
||||
}
|
||||
const auto fan_id = GetStringField(*body, "fan_id");
|
||||
if (!fan_id.has_value() || !body->contains("effective_rows") ||
|
||||
!(*body)["effective_rows"].is_array() || !body->contains("report_rows") ||
|
||||
if (!fan_id.has_value() || !body->contains("report_rows") ||
|
||||
!(*body)["report_rows"].is_array()) {
|
||||
SendError(callback, kErrorInvalidRequest, "报告参数不完整");
|
||||
return;
|
||||
@@ -2049,6 +2315,22 @@ void WindPowerController::ExportReport(
|
||||
return;
|
||||
}
|
||||
TaskReleaseGuard report_guard(job_id);
|
||||
const auto export_started = std::chrono::steady_clock::now();
|
||||
|
||||
const auto cache_key = std::to_string(std::hash<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 {
|
||||
json metadata;
|
||||
@@ -2075,7 +2357,26 @@ void WindPowerController::ExportReport(
|
||||
return;
|
||||
}
|
||||
|
||||
const auto report_path = JobDir(job_id) / ("report_" + FileNameForFan(fan_id.value()) + ".xlsx");
|
||||
const auto point_file = result.value("point_files", json::object()).value(fan_id.value(), "");
|
||||
if (point_file.empty() || point_file.find("..") != std::string::npos) {
|
||||
SendError(callback, kErrorJobNotFound, "风机点数据不存在或已过期");
|
||||
return;
|
||||
}
|
||||
json point_data;
|
||||
{ std::ifstream input(JobPointsDir(job_id) / point_file); input >> point_data; }
|
||||
const auto restored_ids = PointIdSet(*body, "restored_point_ids");
|
||||
const auto erased_ids = PointIdSet(*body, "erased_point_ids");
|
||||
json effective_rows = json::array();
|
||||
for (const auto& point : point_data.value("scatter_points", json::array())) {
|
||||
if (point.is_object() && erased_ids.count(PointIdFor(point, fan_id.value())) == 0) {
|
||||
effective_rows.push_back(point);
|
||||
}
|
||||
}
|
||||
for (const auto& point : point_data.value("filtered_points", json::array())) {
|
||||
if (point.is_object() && restored_ids.count(PointIdFor(point, fan_id.value())) != 0) {
|
||||
effective_rows.push_back(point);
|
||||
}
|
||||
}
|
||||
lxw_workbook_options options{};
|
||||
const auto temp_dir = JobDir(job_id).string();
|
||||
options.constant_memory = LXW_TRUE;
|
||||
@@ -2092,23 +2393,41 @@ void WindPowerController::ExportReport(
|
||||
|
||||
lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据");
|
||||
worksheet_freeze_panes(detail, 1, 0);
|
||||
const bool include_pitch = result.value("scheme", json::object()).value("id", "") == kSchemeTwoId;
|
||||
const std::vector<std::string> detail_headers = include_pitch
|
||||
? std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"}
|
||||
: std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"};
|
||||
const auto report_scheme_id =
|
||||
result.value("scheme", json::object()).value("id", "");
|
||||
const bool include_pitch = report_scheme_id == kSchemeTwoId;
|
||||
const bool include_rotor_speed = report_scheme_id == kSchemeThreeId;
|
||||
std::vector<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) {
|
||||
worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format);
|
||||
worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr);
|
||||
}
|
||||
lxw_row_t detail_row = 1;
|
||||
for (const auto& point : (*body)["effective_rows"]) {
|
||||
for (const auto& point : effective_rows) {
|
||||
if (!point.is_object()) continue;
|
||||
worksheet_write_string(detail, detail_row, 0, point.value("fan_id", fan_id.value()).c_str(), nullptr);
|
||||
worksheet_write_string(detail, detail_row, 1, point.value("time", "").c_str(), nullptr);
|
||||
worksheet_write_number(detail, detail_row, 2, point.value("active_power", 0.0), number_format);
|
||||
worksheet_write_number(detail, detail_row, 3, point.value("generator_speed", 0.0), number_format);
|
||||
worksheet_write_number(detail, detail_row, 4, point.value("wind_speed", 0.0), number_format);
|
||||
if (include_pitch) worksheet_write_number(detail, detail_row, 5, point.value("pitch_angle_average", 0.0), number_format);
|
||||
lxw_col_t detail_column = 4;
|
||||
if (include_rotor_speed) {
|
||||
worksheet_write_number(detail, detail_row, detail_column++,
|
||||
point.value("rotor_speed", 0.0), number_format);
|
||||
}
|
||||
worksheet_write_number(detail, detail_row, detail_column++,
|
||||
point.value("wind_speed", 0.0), number_format);
|
||||
if (include_pitch) {
|
||||
worksheet_write_number(detail, detail_row, detail_column,
|
||||
point.value("pitch_angle_average", 0.0), number_format);
|
||||
}
|
||||
++detail_row;
|
||||
}
|
||||
worksheet_autofilter(detail, 0, 0, std::max<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);
|
||||
const auto frequency = "=(COUNTIFS('筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + ")/COUNT('筛选前的数据'!" + raw_wind + ":" + raw_wind + "))*8760";
|
||||
worksheet_write_formula(curve, curve_row, 2, frequency.c_str(), number_format);
|
||||
const auto actual = "=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!$E:$E,\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选后的数据'!$E:$E,\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + "),0)";
|
||||
const auto detail_wind_column = include_rotor_speed ? "$F:$F" : "$E:$E";
|
||||
const auto actual = std::string(
|
||||
"=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!") +
|
||||
detail_wind_column + ",\">=\"&B" + std::to_string(excel_row) + "-" +
|
||||
interval_text + ",'筛选后的数据'!" + detail_wind_column + ",\"<\"&B" +
|
||||
std::to_string(excel_row) + "+" + interval_text + "),0)";
|
||||
worksheet_write_formula(curve, curve_row, 3, actual.c_str(), number_format);
|
||||
worksheet_write_number(curve, curve_row, 4, point.value("design_power", 0.0), number_format);
|
||||
const auto generated = "=IFERROR(ROUND(C" + std::to_string(excel_row) + "*D" + std::to_string(excel_row) + "/1000,4),0)";
|
||||
@@ -2195,10 +2519,16 @@ void WindPowerController::ExportReport(
|
||||
&image_options);
|
||||
}
|
||||
if (workbook_close(workbook) != LXW_NO_ERROR) throw std::runtime_error("写入 Excel 文件失败");
|
||||
callback(HttpResponse::newFileResponse(report_path.string(),
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::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",
|
||||
CT_CUSTOM,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response->addHeader("Server-Timing", "report-generate;dur=" + std::to_string(elapsed_ms));
|
||||
callback(response);
|
||||
} catch (const std::exception&) {
|
||||
SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public:
|
||||
ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post);
|
||||
ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", Post);
|
||||
ADD_METHOD_TO(WindPowerController::FinishJob, "/api/wind/jobs/finish", Post);
|
||||
ADD_METHOD_TO(WindPowerController::GetFanPoints,
|
||||
"/api/wind/jobs/{1}/fans/{2}/points", Get);
|
||||
ADD_METHOD_TO(WindPowerController::ExportReport, "/api/wind/jobs/{1}/report", Post);
|
||||
ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete);
|
||||
ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get);
|
||||
@@ -39,6 +41,10 @@ public:
|
||||
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||
void FinishJob(const HttpRequestPtr& req,
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr&)>&& callback,
|
||||
const std::string& job_id);
|
||||
|
||||
+42
-2
@@ -2,19 +2,59 @@
|
||||
#include <trantor/utils/Logger.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "auth/AuthManager.h"
|
||||
#include "controllers/AuthController.h"
|
||||
#include "controllers/WindPowerController.h"
|
||||
#include "utils/ResponseUtil.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
int main() {
|
||||
// 抑制 Drogon/trantor 内部日志噪声
|
||||
trantor::Logger::setLogLevel(trantor::Logger::kFatal);
|
||||
// 保留任务耗时日志,便于定位上传、清洗与回传瓶颈。
|
||||
trantor::Logger::setLogLevel(trantor::Logger::kInfo);
|
||||
|
||||
// 加载 Drogon 配置(监听端口 / CORS / 静态资源根目录)
|
||||
LOG_INFO << "Loading server configuration...";
|
||||
app().loadConfigFile("config/server_config.json");
|
||||
if (!AuthManager::Instance().Initialize()) {
|
||||
LOG_ERROR << "Failed to initialize account database";
|
||||
return 1;
|
||||
}
|
||||
app().registerController(std::make_shared<AuthController>());
|
||||
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,交由前端路由处理
|
||||
app().setCustom404Page(
|
||||
|
||||
@@ -109,7 +109,7 @@ SERVICE_NAME="${SERVICE_NAME}"
|
||||
mkdir -p "$HOME/wind_power/logs"
|
||||
sudo cp "/tmp/${SERVICE_NAME}.service" "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
sudo apt-get update -qq 2>/dev/null || true
|
||||
sudo apt-get install -y -qq libjsoncpp25 libc-ares2 >/dev/null 2>&1 || true
|
||||
sudo apt-get install -y -qq libjsoncpp25 libc-ares2 libsqlite3-0 >/dev/null 2>&1 || true
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
sudo systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.currentSchemeBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 10px;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.currentSchemeLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.currentSchemeLabel span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.currentSchemeLabel strong {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.currentSchemeBanner p {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.homeHeader h1 {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
@@ -475,6 +514,95 @@ select:focus {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.calculationProgress {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
background: #f5f7ff;
|
||||
}
|
||||
|
||||
.calculationProgress.complete {
|
||||
border-color: #a7f3d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.calculationProgressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calculationProgressHeader span {
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.calculationSteps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.calculationStep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calculationStep i {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calculationStep.active { color: #4338ca; font-weight: 600; }
|
||||
.calculationStep.active i { background: #6366f1; color: #ffffff; }
|
||||
.calculationStep.completed { color: #047857; }
|
||||
.calculationStep.completed i,
|
||||
.calculationProgress.complete .calculationStep i { background: #10b981; color: #ffffff; }
|
||||
|
||||
.calculationProgressTrack {
|
||||
height: 7px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.calculationProgressTrack span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.calculationProgress.complete .calculationProgressTrack span { background: #10b981; }
|
||||
.calculationProgress p { margin: 9px 0 0; color: #475569; font-size: 13px; }
|
||||
|
||||
.calculationTiming {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.headerPreview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -770,6 +898,12 @@ td {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.currentSchemeBanner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldControl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -782,6 +916,8 @@ td {
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calculationSteps { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* ── 浅色主题覆盖 ── */
|
||||
@@ -928,3 +1064,140 @@ td { color: #334155; border-bottom-color: #e6edf5; }
|
||||
@media (max-width: 560px) {
|
||||
.chartSettingsGrid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.authLoading {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.authLoading { color: #64748b; }
|
||||
|
||||
.accountPage h1 { color: #172033; }
|
||||
.accountPageHeader p { margin-top: 6px; color: #64748b; }
|
||||
|
||||
.accountForm label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accountExpiryField {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accountExpiryInputs,
|
||||
.tableExpiryEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accountForm .accountPermanentToggle,
|
||||
.tableExpiryEditor .accountPermanentToggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accountForm .accountPermanentToggle input,
|
||||
.tableExpiryEditor .accountPermanentToggle input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
|
||||
.accountExpiryInputs > input[type='date'] { min-width: 0; }
|
||||
.accountExpiryInputs > input[type='date']:disabled,
|
||||
.tableExpiryEditor .tableInput:disabled { background: #f1f5f9; color: #94a3b8; }
|
||||
.tableExpiryEditor .tableInput { min-width: 138px; }
|
||||
|
||||
.accountActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accountEnableButton,
|
||||
.accountDisableButton {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accountEnableButton { border: 1px solid #86efac; color: #047857; }
|
||||
.accountEnableButton:hover { background: #ecfdf5; }
|
||||
.accountDisableButton { border: 1px solid #fecaca; color: #b91c1c; }
|
||||
.accountDisableButton:hover { background: #fef2f2; }
|
||||
.accountEnableButton:disabled,
|
||||
.accountDisableButton:disabled { cursor: not-allowed; opacity: 0.52; }
|
||||
|
||||
.accountForm input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.accountForm input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.accountPage {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 56px;
|
||||
}
|
||||
|
||||
.accountPageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.accountCreatePanel h2 {
|
||||
margin-bottom: 16px;
|
||||
color: #172033;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.accountForm {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.accountForm { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.accountForm { grid-template-columns: 1fr; }
|
||||
.accountPageHeader { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import AppLayout from './components/AppLayout/AppLayout';
|
||||
import AdminAccountsPage from './pages/AdminAccountsPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { getCurrentUser, logout } from './utils/api';
|
||||
import './App.css';
|
||||
|
||||
function AuthenticatedApp({ user, onLogout }) {
|
||||
const location = useLocation();
|
||||
const activeSection = location.pathname === '/settings'
|
||||
? 'settings'
|
||||
: location.pathname === '/calculation'
|
||||
? 'calculation'
|
||||
: null;
|
||||
|
||||
if (!activeSection && location.pathname !== '/admin/accounts') {
|
||||
return <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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* SPA 回退:未匹配路由统一回到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<AppContent />
|
||||
</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,
|
||||
downloadWindReport,
|
||||
finishWindJob,
|
||||
getWindFanPoints,
|
||||
getWindChartOptions,
|
||||
getWindSchemes,
|
||||
saveWindChartOptions,
|
||||
@@ -17,33 +18,18 @@ import {
|
||||
alignDesignCurveToActualCurve,
|
||||
buildPowerCurveModel,
|
||||
getDrawableActualCurve,
|
||||
hasDrawablePowerCurveData,
|
||||
} from '../utils/powerCurveModel';
|
||||
import { resolvePowerCurveLegendLayout } from '../utils/powerCurveLegendLayout';
|
||||
import {
|
||||
BASE_REQUIRED_FIELDS,
|
||||
inferWindMapping,
|
||||
normalizeHeader,
|
||||
PITCH_REQUIRED_FIELDS,
|
||||
ROTOR_SPEED_FIELD,
|
||||
} from '../utils/windFieldMapping';
|
||||
|
||||
const REQUIRED_FIELDS = [
|
||||
{ key: 'time', label: '时间' },
|
||||
{ key: 'fan_id', label: '风机编号' },
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
{ key: 'active_power', label: '有功功率' },
|
||||
{ key: 'generator_speed', label: '发电机转速' },
|
||||
{ key: 'blade_pitch_1', label: '1#叶片角度' },
|
||||
{ key: 'blade_pitch_2', label: '2#叶片角度' },
|
||||
{ key: 'blade_pitch_3', label: '3#叶片角度' },
|
||||
];
|
||||
|
||||
const FIELD_HINTS = {
|
||||
time: ['时间', 'time', 'timestamp', '日期'],
|
||||
fan_id: ['风机编号', '风机', '机组编号', 'fan', 'turbine'],
|
||||
wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'],
|
||||
active_power: ['平均有功功率', '有功功率', 'active power', 'power'],
|
||||
generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'],
|
||||
blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'],
|
||||
blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'],
|
||||
blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'],
|
||||
};
|
||||
|
||||
const FIELD_EXCLUDES = {
|
||||
active_power: ['限功率', '限电', '时间', '累计'],
|
||||
};
|
||||
const CALCULATION_STEPS = ['准备数据', '上传数据', '清洗计算', '计算完成'];
|
||||
|
||||
const DESIGN_FIELDS = [
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
@@ -55,13 +41,14 @@ const DESIGN_FIELD_HINTS = {
|
||||
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 4000;
|
||||
const MAX_UPLOAD_CHUNK_BYTES = 6 * 1024 * 1024;
|
||||
const CHART_HEIGHT = 580;
|
||||
const BRUSH_RADIUS = 10;
|
||||
const EDIT_TOOL_RESTORE = 'restore';
|
||||
const EDIT_TOOL_ERASE = 'erase';
|
||||
const DEFAULT_SCHEME_ID = 'scheme_one';
|
||||
const SCHEME_TWO_ID = 'scheme_two';
|
||||
const SCHEME_THREE_ID = 'scheme_three';
|
||||
const DEFAULT_SCHEMES = [
|
||||
{
|
||||
id: 'scheme_one',
|
||||
@@ -93,6 +80,24 @@ const DEFAULT_SCHEMES = [
|
||||
report_wind_speed_interval: 0.25,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SCHEME_THREE_ID,
|
||||
name: '方案三',
|
||||
description: '叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比',
|
||||
parameters: {
|
||||
rated_power: 4800,
|
||||
rated_wind_speed: 14,
|
||||
power_step: 5,
|
||||
cleaning_wind_speed_step: 0.25,
|
||||
wind_speed_change_threshold: 1,
|
||||
iqr_lower_multiplier: 1.2,
|
||||
iqr_upper_multiplier: 2,
|
||||
minimum_generator_speed: 1,
|
||||
generator_speed_k: 0.9,
|
||||
rotor_radius: 78,
|
||||
report_wind_speed_interval: 0.25,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SCHEME_TWO_PARAMS = {
|
||||
@@ -115,6 +120,19 @@ const DEFAULT_SCHEME_ONE_PARAMS = {
|
||||
gearbox_ratio: '162',
|
||||
report_wind_speed_interval: '0.25',
|
||||
};
|
||||
const DEFAULT_SCHEME_THREE_PARAMS = {
|
||||
rated_power: '4800',
|
||||
rated_wind_speed: '14',
|
||||
power_step: '5',
|
||||
cleaning_wind_speed_step: '0.25',
|
||||
wind_speed_change_threshold: '1',
|
||||
iqr_lower_multiplier: '1.2',
|
||||
iqr_upper_multiplier: '2',
|
||||
minimum_generator_speed: '1',
|
||||
generator_speed_k: '0.9',
|
||||
rotor_radius: '78',
|
||||
report_wind_speed_interval: '0.25',
|
||||
};
|
||||
const SCHEME_ONE_PARAM_FIELDS = [
|
||||
{ key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' },
|
||||
{ key: 'rated_wind_speed', label: '额定风速', unit: 'm/s', min: '0', step: '0.01' },
|
||||
@@ -129,6 +147,9 @@ const SCHEME_ONE_PARAM_FIELDS = [
|
||||
{ key: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' },
|
||||
{ key: 'report_wind_speed_interval', label: '报告公式风速区间半宽', unit: 'm/s', min: '0', step: '0.01' },
|
||||
];
|
||||
const SCHEME_THREE_PARAM_FIELDS = SCHEME_ONE_PARAM_FIELDS.filter(
|
||||
(field) => field.key !== 'gearbox_ratio',
|
||||
);
|
||||
const CHART_STATE_KEY = 'wind_power_chart_state_v1';
|
||||
const DEFAULT_CHART_OPTIONS = {
|
||||
title: '#机组功率曲线',
|
||||
@@ -206,50 +227,6 @@ function createDesignRows(points) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[()()_\-./]/g, '');
|
||||
}
|
||||
|
||||
function inferMapping(headers) {
|
||||
const normalized = headers.map((header) => ({
|
||||
header,
|
||||
normalized: normalizeHeader(header),
|
||||
}));
|
||||
const mapping = {};
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
const hints = FIELD_HINTS[field.key].map(normalizeHeader);
|
||||
const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader);
|
||||
let best = null;
|
||||
for (const item of normalized) {
|
||||
if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) {
|
||||
continue;
|
||||
}
|
||||
let score = 0;
|
||||
hints.forEach((hint, index) => {
|
||||
const weight = hints.length - index;
|
||||
if (item.normalized === hint) {
|
||||
score = Math.max(score, 100 + weight);
|
||||
} else if (item.normalized.includes(hint)) {
|
||||
score = Math.max(score, 60 + weight);
|
||||
} else if (hint.includes(item.normalized)) {
|
||||
score = Math.max(score, 20 + weight);
|
||||
}
|
||||
});
|
||||
if (!best || score > best.score) {
|
||||
best = { ...item, score };
|
||||
}
|
||||
}
|
||||
mapping[field.key] = best && best.score > 0 ? best.header : '';
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function inferDesignMapping(headers) {
|
||||
const normalized = headers.map((header) => ({
|
||||
header,
|
||||
@@ -285,57 +262,10 @@ function inferDesignMapping(headers) {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function excelSerialToDate(value) {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return null;
|
||||
}
|
||||
const utcDays = Math.floor(days - 25569);
|
||||
const utcValue = utcDays * 86400;
|
||||
const dateInfo = new Date(utcValue * 1000);
|
||||
const fractionalDay = days - Math.floor(days) + 0.0000001;
|
||||
const totalSeconds = Math.floor(86400 * fractionalDay);
|
||||
dateInfo.setSeconds(totalSeconds);
|
||||
return dateInfo;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function normalizeTime(value) {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
return formatDate(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
const date = excelSerialToDate(value);
|
||||
return date ? formatDate(date) : '';
|
||||
}
|
||||
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const isoMatch = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||||
if (isoMatch) {
|
||||
const [, year, month, day, hour, minute, second = '0'] = isoMatch;
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||||
}
|
||||
|
||||
const slashMatch = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||||
if (slashMatch) {
|
||||
const [, month, day, rawYear, hour, minute, second = '0'] = slashMatch;
|
||||
const year = rawYear.length === 2 ? `20${rawYear}` : rawYear;
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
@@ -439,38 +369,35 @@ function getCell(row, headerIndex) {
|
||||
return row[headerIndex];
|
||||
}
|
||||
|
||||
function buildStandardRows(files, mapping) {
|
||||
const firstHeaders = files[0]?.headers || [];
|
||||
const indexes = Object.fromEntries(
|
||||
REQUIRED_FIELDS.map((field) => [field.key, firstHeaders.indexOf(mapping[field.key])]),
|
||||
);
|
||||
|
||||
const rows = [];
|
||||
function buildRawRows(files) {
|
||||
const rawRows = [];
|
||||
for (const file of files) {
|
||||
const localIndexes = Object.fromEntries(
|
||||
REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]),
|
||||
);
|
||||
for (let rowIndex = 0; rowIndex < file.rows.length; rowIndex += 1) {
|
||||
const row = file.rows[rowIndex];
|
||||
rows.push({
|
||||
time: normalizeTime(getCell(row, localIndexes.time)),
|
||||
fan_id: String(getCell(row, localIndexes.fan_id) ?? '').trim(),
|
||||
wind_speed: normalizeNumber(getCell(row, localIndexes.wind_speed)),
|
||||
active_power: normalizeNumber(getCell(row, localIndexes.active_power)),
|
||||
generator_speed: normalizeNumber(getCell(row, localIndexes.generator_speed)),
|
||||
blade_pitch_1: normalizeNumber(getCell(row, localIndexes.blade_pitch_1)),
|
||||
blade_pitch_2: normalizeNumber(getCell(row, localIndexes.blade_pitch_2)),
|
||||
blade_pitch_3: normalizeNumber(getCell(row, localIndexes.blade_pitch_3)),
|
||||
});
|
||||
rawRows.push({
|
||||
file_name: file.file_name,
|
||||
values: [...(file.raw_rows?.[rowIndex] || row)],
|
||||
values: [...(file.raw_rows?.[rowIndex] || file.rows[rowIndex])],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rawRows;
|
||||
}
|
||||
|
||||
return { rows, rawRows, indexes };
|
||||
function splitRawRowsIntoChunks(rawRows) {
|
||||
const chunks = [];
|
||||
let chunk = [];
|
||||
let size = 0;
|
||||
for (const row of rawRows) {
|
||||
const rowSize = JSON.stringify(row).length;
|
||||
if (chunk.length && size + rowSize > MAX_UPLOAD_CHUNK_BYTES) {
|
||||
chunks.push(chunk);
|
||||
chunk = [];
|
||||
size = 0;
|
||||
}
|
||||
chunk.push(row);
|
||||
size += rowSize;
|
||||
}
|
||||
if (chunk.length) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function hasMatchingHeaderSequence(files) {
|
||||
@@ -491,12 +418,13 @@ function sanitizeFileName(value) {
|
||||
return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
function exportValidRowsToExcel(fanId, rows) {
|
||||
function exportValidRowsToExcel(fanId, rows, includeRotorSpeed = false) {
|
||||
const exportRows = (rows || []).map((row) => ({
|
||||
风机编号: row.fan_id || fanId || '',
|
||||
采样时间: row.time || '',
|
||||
平均功率: row.active_power ?? '',
|
||||
平均转速: row.generator_speed ?? '',
|
||||
...(includeRotorSpeed ? { 叶轮转速: row.rotor_speed ?? '' } : {}),
|
||||
平均风速: row.wind_speed ?? '',
|
||||
'3个叶片变桨角平均值': row.pitch_angle_average ?? '',
|
||||
}));
|
||||
@@ -603,6 +531,10 @@ function schemeParamsToState(parameters) {
|
||||
parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed,
|
||||
),
|
||||
rated_power: String(parameters?.rated_power ?? DEFAULT_SCHEME_TWO_PARAMS.rated_power),
|
||||
report_wind_speed_interval: String(
|
||||
parameters?.report_wind_speed_interval
|
||||
?? DEFAULT_SCHEME_TWO_PARAMS.report_wind_speed_interval,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -613,6 +545,37 @@ function schemeOneParamsToState(parameters) {
|
||||
]));
|
||||
}
|
||||
|
||||
function schemeThreeParamsToState(parameters) {
|
||||
return Object.fromEntries(Object.entries(DEFAULT_SCHEME_THREE_PARAMS).map(([field, fallback]) => [
|
||||
field,
|
||||
String(parameters?.[field] ?? fallback),
|
||||
]));
|
||||
}
|
||||
|
||||
function normalizeParams(params) {
|
||||
return Object.fromEntries(Object.entries(params).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
}
|
||||
|
||||
function areBaseSchemeParamsInvalid(values, requireGearboxRatio) {
|
||||
return !Number.isFinite(values.rated_power) || values.rated_power <= 0 ||
|
||||
!Number.isFinite(values.rated_wind_speed) || values.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(values.power_step) || values.power_step <= 0 ||
|
||||
!Number.isFinite(values.cleaning_wind_speed_step) ||
|
||||
values.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(values.rotor_radius) || values.rotor_radius <= 0 ||
|
||||
(requireGearboxRatio &&
|
||||
(!Number.isFinite(values.gearbox_ratio) || values.gearbox_ratio <= 0)) ||
|
||||
!Number.isFinite(values.report_wind_speed_interval) ||
|
||||
values.report_wind_speed_interval <= 0 || values.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(values[field]) || values[field] < 0
|
||||
));
|
||||
}
|
||||
|
||||
function SummaryCards({ summary }) {
|
||||
if (!summary) return null;
|
||||
const cards = [
|
||||
@@ -930,28 +893,44 @@ function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
];
|
||||
context.textAlign = 'left';
|
||||
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
|
||||
const legendLeft = padding.left + 22;
|
||||
const legendTop = padding.top + Math.max(30, typography.legend + 10);
|
||||
const legendLineHeight = Math.max(34, typography.legend + 12);
|
||||
const legendLayout = resolvePowerCurveLegendLayout({
|
||||
plotLeft: padding.left,
|
||||
plotTop: padding.top,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
fontSize: typography.legend,
|
||||
entries,
|
||||
textWidths: entries.map(([label]) => context.measureText(label).width),
|
||||
});
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(padding.left, padding.top, plotWidth, plotHeight);
|
||||
context.clip();
|
||||
entries.forEach(([label, type], index) => {
|
||||
const top = legendTop + index * legendLineHeight;
|
||||
const top = legendLayout.top + index * legendLayout.lineHeight;
|
||||
const markerY = top + legendLayout.textHeight / 2;
|
||||
const textBaseline = top + typography.legend;
|
||||
const isScatter = type === 'scatter';
|
||||
const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null;
|
||||
if (isScatter) {
|
||||
drawMarker(context, legendLeft + 10, top, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity));
|
||||
drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity));
|
||||
} else if (style) {
|
||||
context.save();
|
||||
context.strokeStyle = style.color;
|
||||
context.lineWidth = 3;
|
||||
context.setLineDash(getLineDash(style.lineStyle));
|
||||
context.beginPath(); context.moveTo(legendLeft, top); context.lineTo(legendLeft + 20, top); context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(legendLayout.left, markerY);
|
||||
context.lineTo(legendLayout.left + legendLayout.markerWidth, markerY);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
} else {
|
||||
drawMarker(context, legendLeft + 10, top, 'circle', 4, type);
|
||||
drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, type);
|
||||
}
|
||||
context.fillStyle = textColor;
|
||||
context.fillText(label, legendLeft + 30, top + 7);
|
||||
context.fillText(label, legendLayout.left + legendLayout.labelOffset, textBaseline);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
@@ -1288,7 +1267,14 @@ function PowerCurveChart({
|
||||
chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current());
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined;
|
||||
const hasDrawableData = hasDrawablePowerCurveData({
|
||||
actualCurve: visibleActualCurve,
|
||||
designCurve: visibleDesignCurve,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showFiltered,
|
||||
});
|
||||
if (!chartRef.current || !hasDrawableData) return undefined;
|
||||
|
||||
const transientErasedPointIds = new Set();
|
||||
let redrawFrameId = 0;
|
||||
@@ -1628,7 +1614,13 @@ function PowerCurveChart({
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
if (!actualCurve.length && !validDesign.length) {
|
||||
if (!hasDrawablePowerCurveData({
|
||||
actualCurve,
|
||||
designCurve: validDesign,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showFiltered,
|
||||
})) {
|
||||
return <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 [files, setFiles] = useState([]);
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [reading, setReading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [progressStage, setProgressStage] = useState(-1);
|
||||
const [performanceTimings, setPerformanceTimings] = useState(null);
|
||||
const [loadingFanPoints, setLoadingFanPoints] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(() => savedSessionState.result || null);
|
||||
const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || '');
|
||||
@@ -1724,11 +1720,15 @@ export default function HomePage() {
|
||||
const [schemeMessage, setSchemeMessage] = useState('');
|
||||
const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS);
|
||||
const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS);
|
||||
const [schemeThreeParams, setSchemeThreeParams] = useState(DEFAULT_SCHEME_THREE_PARAMS);
|
||||
|
||||
const headers = files[0]?.headers || [];
|
||||
const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0];
|
||||
const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID;
|
||||
const mappingFields = isSchemeTwo ? REQUIRED_FIELDS : REQUIRED_FIELDS.slice(0, 5);
|
||||
const isSchemeThree = selectedSchemeId === SCHEME_THREE_ID;
|
||||
const mappingFields = isSchemeTwo
|
||||
? [...BASE_REQUIRED_FIELDS, ...PITCH_REQUIRED_FIELDS]
|
||||
: (isSchemeThree ? [...BASE_REQUIRED_FIELDS, ROTOR_SPEED_FIELD] : BASE_REQUIRED_FIELDS);
|
||||
const missingFields = mappingFields.filter((field) => !mapping[field.key]);
|
||||
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
|
||||
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
|
||||
@@ -1797,6 +1797,28 @@ export default function HomePage() {
|
||||
? result.estimated_params[selectedFan]
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!result?.job_id || !selectedFan || result?.scatter_points?.[selectedFan]) return;
|
||||
let cancelled = false;
|
||||
setLoadingFanPoints(true);
|
||||
getWindFanPoints(result.job_id, selectedFan)
|
||||
.then((points) => {
|
||||
if (cancelled) return;
|
||||
setResult((current) => ({
|
||||
...current,
|
||||
scatter_points: { ...(current.scatter_points || {}), [selectedFan]: points.scatter_points || [] },
|
||||
filtered_points: { ...(current.filtered_points || {}), [selectedFan]: points.filtered_points || [] },
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || '加载风机点数据失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingFanPoints(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [result?.job_id, result?.scatter_points, selectedFan]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({
|
||||
@@ -2030,7 +2052,7 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
setExportingReport(true);
|
||||
setReportExportProgress('正在服务器生成完整报告');
|
||||
setReportExportProgress('正在生成报告图表');
|
||||
setError('');
|
||||
try {
|
||||
const chartRenderSnapshot = await chartSnapshotRef.current?.(false);
|
||||
@@ -2038,13 +2060,19 @@ export default function HomePage() {
|
||||
throw new Error('图表尚未完成绘制,请稍后重试');
|
||||
}
|
||||
const chartImageData = renderPowerCurvePng(chartRenderSnapshot);
|
||||
const blob = await downloadWindReport(result.job_id, {
|
||||
setReportExportProgress('正在上传图表和编辑结果');
|
||||
const report = await downloadWindReport(result.job_id, {
|
||||
fan_id: selectedFan,
|
||||
effective_rows: effectiveScatter,
|
||||
report_rows: reportCurveRows,
|
||||
restored_point_ids: restoredPointIds[selectedFan] || [],
|
||||
erased_point_ids: erasedPointIds[selectedFan] || [],
|
||||
chart_image: chartImageData || '',
|
||||
});
|
||||
downloadReportBlob(blob, selectedFan);
|
||||
setReportExportProgress('正在下载完整报告');
|
||||
downloadReportBlob(report.blob, selectedFan);
|
||||
if (report.serverTiming) {
|
||||
setReportExportNotice(`报告已生成(${report.serverTiming})`);
|
||||
}
|
||||
setExportingReport(false);
|
||||
setReportExportProgress('');
|
||||
} catch (err) {
|
||||
@@ -2056,12 +2084,13 @@ export default function HomePage() {
|
||||
}
|
||||
}, [
|
||||
canExportFullReport,
|
||||
effectiveScatter,
|
||||
exportingReport,
|
||||
reportExportDisabledReason,
|
||||
reportCurveRows,
|
||||
selectedFan,
|
||||
result?.job_id,
|
||||
restoredPointIds,
|
||||
erasedPointIds,
|
||||
]);
|
||||
|
||||
const handleCancelReportExport = useCallback(() => {
|
||||
@@ -2090,6 +2119,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters));
|
||||
const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID);
|
||||
setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters));
|
||||
const schemeThree = loadedSchemes.find((scheme) => scheme.id === SCHEME_THREE_ID);
|
||||
setSchemeThreeParams(schemeThreeParamsToState(schemeThree?.parameters));
|
||||
} catch (err) {
|
||||
if (!canceled) {
|
||||
setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案');
|
||||
@@ -2111,6 +2142,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters));
|
||||
} else if (value === SCHEME_TWO_ID) {
|
||||
setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters));
|
||||
} else if (value === SCHEME_THREE_ID) {
|
||||
setSchemeThreeParams(schemeThreeParamsToState(nextScheme?.parameters));
|
||||
}
|
||||
setSchemeMessage('');
|
||||
}
|
||||
@@ -2129,6 +2162,13 @@ export default function HomePage() {
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSchemeThreeParamChange(field, value) {
|
||||
setSchemeThreeParams((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSaveSchemeDescription() {
|
||||
if (!selectedScheme) return;
|
||||
|
||||
@@ -2136,30 +2176,20 @@ export default function HomePage() {
|
||||
setError('');
|
||||
setSchemeMessage('');
|
||||
try {
|
||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||
const reportWindSpeedInterval = normalizeNumber(schemeTwoParams.report_wind_speed_interval);
|
||||
if (selectedScheme.id === DEFAULT_SCHEME_ID &&
|
||||
(!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || schemeOneValues.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.report_wind_speed_interval) ||
|
||||
schemeOneValues.report_wind_speed_interval <= 0 ||
|
||||
schemeOneValues.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
|
||||
)))) {
|
||||
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||
throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||
}
|
||||
if (selectedScheme.id === SCHEME_THREE_ID &&
|
||||
areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||
throw new Error('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||
}
|
||||
if (selectedScheme.id === SCHEME_TWO_ID &&
|
||||
(!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 ||
|
||||
!Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 ||
|
||||
@@ -2171,6 +2201,7 @@ export default function HomePage() {
|
||||
const data = await saveWindSchemeDescription(selectedScheme.id, {
|
||||
description: schemeDescription,
|
||||
...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}),
|
||||
...(selectedScheme.id === SCHEME_THREE_ID ? { parameters: schemeThreeValues } : {}),
|
||||
...(selectedScheme.id === SCHEME_TWO_ID ? {
|
||||
parameters: {
|
||||
grid_connected_speed: gridConnectedSpeed,
|
||||
@@ -2189,6 +2220,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters));
|
||||
} else if (savedScheme.id === SCHEME_TWO_ID) {
|
||||
setSchemeTwoParams(schemeParamsToState(savedScheme.parameters));
|
||||
} else if (savedScheme.id === SCHEME_THREE_ID) {
|
||||
setSchemeThreeParams(schemeThreeParamsToState(savedScheme.parameters));
|
||||
}
|
||||
setSchemeMessage('已保存');
|
||||
} catch (err) {
|
||||
@@ -2205,6 +2238,10 @@ export default function HomePage() {
|
||||
setReading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setProgress('');
|
||||
setProgressPercent(0);
|
||||
setProgressStage(-1);
|
||||
setPerformanceTimings(null);
|
||||
setSelectedFan('');
|
||||
setShowFiltered(false);
|
||||
setIsEditMode(false);
|
||||
@@ -2221,7 +2258,7 @@ export default function HomePage() {
|
||||
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
|
||||
}
|
||||
setFiles(parsedFiles);
|
||||
setMapping(inferMapping(parsedFiles[0].headers));
|
||||
setMapping(inferWindMapping(parsedFiles[0].headers));
|
||||
} catch (err) {
|
||||
setError(err.message || '读取 Excel 失败');
|
||||
} finally {
|
||||
@@ -2285,29 +2322,17 @@ export default function HomePage() {
|
||||
setError('请先上传文件并完成所有字段映射');
|
||||
return;
|
||||
}
|
||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
const schemeOneInvalid =
|
||||
!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.cleaning_wind_speed_step) ||
|
||||
schemeOneValues.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.report_wind_speed_interval) ||
|
||||
schemeOneValues.report_wind_speed_interval <= 0 ||
|
||||
schemeOneValues.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
|
||||
));
|
||||
if (!isSchemeTwo && schemeOneInvalid) {
|
||||
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||
if (!isSchemeTwo && !isSchemeThree &&
|
||||
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||
setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||
return;
|
||||
}
|
||||
if (isSchemeThree && areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||
setError('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||
return;
|
||||
}
|
||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||
@@ -2332,10 +2357,17 @@ export default function HomePage() {
|
||||
setRestoredPointIds({});
|
||||
setErasedPointIds({});
|
||||
setConfirmedCurveScatterByFan({});
|
||||
setPerformanceTimings(null);
|
||||
setProgress('正在准备标准化数据');
|
||||
setProgressPercent(5);
|
||||
setProgressStage(0);
|
||||
|
||||
try {
|
||||
const { rows, rawRows } = buildStandardRows(files, mapping);
|
||||
const totalStartedAt = performance.now();
|
||||
const rawRows = buildRawRows(files);
|
||||
const chunks = splitRawRowsIntoChunks(rawRows);
|
||||
setProgress('正在创建计算任务');
|
||||
setProgressPercent(10);
|
||||
const start = await startWindJob({
|
||||
files: files.map((file) => ({
|
||||
file_name: file.file_name,
|
||||
@@ -2346,25 +2378,29 @@ export default function HomePage() {
|
||||
});
|
||||
jobId = start.job_id;
|
||||
|
||||
const chunkCount = Math.ceil(rows.length / CHUNK_SIZE);
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunkRows = rows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
|
||||
setProgress(`正在上传数据分片 ${index + 1}/${chunkCount}`);
|
||||
const uploadStartedAt = performance.now();
|
||||
setProgressStage(1);
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
setProgress(`正在上传原始数据分片 ${index + 1}/${chunks.length}`);
|
||||
setProgressPercent(15 + Math.round((index / chunks.length) * 55));
|
||||
await uploadWindChunk({
|
||||
job_id: jobId,
|
||||
chunk_index: index,
|
||||
rows: chunkRows,
|
||||
raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE),
|
||||
raw_rows: chunks[index],
|
||||
});
|
||||
setProgressPercent(15 + Math.round(((index + 1) / chunks.length) * 55));
|
||||
}
|
||||
|
||||
setProgress('正在清洗数据并计算功率曲线');
|
||||
setProgressPercent(75);
|
||||
setProgressStage(2);
|
||||
const calculation = await finishWindJob({
|
||||
job_id: jobId,
|
||||
options: {
|
||||
curve_wind_speed_step: 0.5,
|
||||
scheme_id: selectedSchemeId,
|
||||
...(!isSchemeTwo ? schemeOneValues : {}),
|
||||
...(!isSchemeTwo && !isSchemeThree ? schemeOneValues : {}),
|
||||
...(isSchemeThree ? schemeThreeValues : {}),
|
||||
...(isSchemeTwo ? {
|
||||
grid_connected_speed: gridConnectedSpeed,
|
||||
rated_generator_speed: ratedGeneratorSpeed,
|
||||
@@ -2373,9 +2409,17 @@ export default function HomePage() {
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
const initialFan = calculation.initial_fan_id || calculation.fans?.[0] || '';
|
||||
setResult({ ...calculation, job_id: jobId });
|
||||
setSelectedFan(calculation.fans?.[0] || '');
|
||||
setSelectedFan(initialFan);
|
||||
setPerformanceTimings({
|
||||
...(calculation.timings_ms || {}),
|
||||
upload: Math.round(performance.now() - uploadStartedAt),
|
||||
total: Math.round(performance.now() - totalStartedAt),
|
||||
});
|
||||
setProgress('计算完成');
|
||||
setProgressPercent(100);
|
||||
setProgressStage(3);
|
||||
} catch (err) {
|
||||
if (jobId) {
|
||||
try {
|
||||
@@ -2390,6 +2434,8 @@ export default function HomePage() {
|
||||
setReportExportNotice(message);
|
||||
}
|
||||
setProgress('');
|
||||
setProgressPercent(0);
|
||||
setProgressStage(-1);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -2399,10 +2445,14 @@ export default function HomePage() {
|
||||
<div className="home">
|
||||
<header className="homeHeader">
|
||||
<div>
|
||||
<h1>风电功率计算平台</h1>
|
||||
<p className="subtitle">多 Excel 导入、字段映射、数据清洗与功率曲线计算</p>
|
||||
<h1>{activeSection === 'settings' ? '参数设置' : '风电功率计算'}</h1>
|
||||
<p className="subtitle">
|
||||
{activeSection === 'settings'
|
||||
? '配置计算方案与设计功率曲线'
|
||||
: '多 Excel 导入、字段映射、数据清洗与功率曲线计算'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="headerActions">
|
||||
{activeSection === 'calculation' && <div className="headerActions">
|
||||
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="file"
|
||||
@@ -2413,11 +2463,19 @@ export default function HomePage() {
|
||||
/>
|
||||
{reading ? '解析中' : '上传 Excel'}
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
</header>
|
||||
|
||||
{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 && (
|
||||
<div className="dialogBackdrop" role="presentation">
|
||||
<section className="appDialog" role="dialog" aria-modal="true" aria-labelledby="report-export-notice-title">
|
||||
@@ -2432,7 +2490,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel schemePanel">
|
||||
<section className="panel schemePanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>计算方案</h2>
|
||||
@@ -2469,18 +2527,24 @@ export default function HomePage() {
|
||||
{!isSchemeTwo && (
|
||||
<>
|
||||
<p className="schemeParamHint">
|
||||
叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速
|
||||
{isSchemeThree
|
||||
? '叶尖速比 = 叶轮转速 × 3.14 × 叶轮半径 × 30 ÷ 风速'
|
||||
: '叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速'}
|
||||
</p>
|
||||
<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}>
|
||||
<span>{field.label}{field.unit ? ` (${field.unit})` : ''}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={field.min}
|
||||
step={field.step}
|
||||
value={schemeOneParams[field.key]}
|
||||
onChange={(event) => handleSchemeOneParamChange(field.key, event.target.value)}
|
||||
value={(isSchemeThree ? schemeThreeParams : schemeOneParams)[field.key]}
|
||||
onChange={(event) => (
|
||||
isSchemeThree
|
||||
? handleSchemeThreeParamChange(field.key, event.target.value)
|
||||
: handleSchemeOneParamChange(field.key, event.target.value)
|
||||
)}
|
||||
disabled={submitting || savingScheme}
|
||||
placeholder="请输入"
|
||||
/>
|
||||
@@ -2567,7 +2631,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel designPowerPanel">
|
||||
<section className="panel designPowerPanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>设计功率曲线</h2>
|
||||
@@ -2650,7 +2714,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="workspace">
|
||||
<section className="workspace" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>1. 数据文件</h2>
|
||||
@@ -2699,6 +2763,36 @@ export default function HomePage() {
|
||||
))}
|
||||
</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">
|
||||
<button
|
||||
className="primaryButton"
|
||||
@@ -2712,7 +2806,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<section className="panel" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<h2>3. 列头预览</h2>
|
||||
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
||||
@@ -2729,7 +2823,7 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{result && (
|
||||
<section className="resultSection">
|
||||
<section className="resultSection" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>4. 清洗结果</h2>
|
||||
@@ -2758,6 +2852,7 @@ export default function HomePage() {
|
||||
<span>
|
||||
曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点
|
||||
· 滤除 {visibleFilteredPoints.length.toLocaleString()} 点
|
||||
{loadingFanPoints ? ' · 正在加载点数据…' : ''}
|
||||
</span>
|
||||
<button
|
||||
className="secondaryButton"
|
||||
@@ -2807,7 +2902,11 @@ export default function HomePage() {
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
|
||||
onClick={() => exportValidRowsToExcel(
|
||||
selectedFan,
|
||||
effectiveScatter,
|
||||
isSchemeThree,
|
||||
)}
|
||||
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}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
@@ -33,6 +34,11 @@ async function request(url, options = {}) {
|
||||
|
||||
// 业务失败
|
||||
if (json.status !== 0) {
|
||||
if (resp.status === 401 && url !== '/auth/login' && url !== '/auth/me') {
|
||||
window.dispatchEvent(new CustomEvent('wind-auth-required', {
|
||||
detail: { message: json.msg || '请先登录' },
|
||||
}));
|
||||
}
|
||||
throw new Error(json.msg || '请求失败');
|
||||
}
|
||||
return json.data;
|
||||
@@ -54,6 +60,39 @@ export function getHealth() {
|
||||
return request('/system/health');
|
||||
}
|
||||
|
||||
export function login(username, password) {
|
||||
return request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return request('/admin/accounts');
|
||||
}
|
||||
|
||||
export function createAccount(payload) {
|
||||
return request('/admin/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(username, payload) {
|
||||
return request(`/admin/accounts/${encodeURIComponent(username)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计算方案列表
|
||||
* @returns {Promise<{default_scheme_id: string, schemes: Array<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}>}
|
||||
*/
|
||||
export function uploadWindChunk(payload) {
|
||||
@@ -131,6 +170,11 @@ export function finishWindJob(payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取单台风机的完整有效点与过滤点。 */
|
||||
export function getWindFanPoints(jobId, fanId) {
|
||||
return request(`/wind/jobs/${encodeURIComponent(jobId)}/fans/${encodeURIComponent(fanId)}/points`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未完成的计算任务
|
||||
* @param {string} jobId
|
||||
@@ -147,6 +191,7 @@ export async function downloadWindReport(jobId, payload) {
|
||||
const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -154,11 +199,19 @@ export async function downloadWindReport(jobId, payload) {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (resp.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('wind-auth-required', {
|
||||
detail: { message: json.msg || '请先登录' },
|
||||
}));
|
||||
}
|
||||
throw new Error(json.msg || '完整报告生成失败');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return resp.blob();
|
||||
return {
|
||||
blob: await resp.blob(),
|
||||
serverTiming: resp.headers.get('Server-Timing') || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
export function hasDrawablePowerCurveData({
|
||||
actualCurve = [],
|
||||
designCurve = [],
|
||||
scatterPoints = [],
|
||||
filteredPoints = [],
|
||||
showFiltered = false,
|
||||
} = {}) {
|
||||
return Boolean(actualCurve.length || designCurve.length || scatterPoints.length ||
|
||||
(showFiltered && filteredPoints.length));
|
||||
}
|
||||
|
||||
function interpolatePowerAtWindSpeed(designCurve, windSpeed) {
|
||||
if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON
|
||||
|| windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildActualCurveFromDesign, getDrawableActualCurve } from './powerCurveModel.js';
|
||||
import {
|
||||
buildActualCurveFromDesign,
|
||||
getDrawableActualCurve,
|
||||
hasDrawablePowerCurveData,
|
||||
} from './powerCurveModel.js';
|
||||
|
||||
test('getDrawableActualCurve skips empty design bins and keeps adjacent valid points', () => {
|
||||
const actualCurve = buildActualCurveFromDesign(
|
||||
@@ -27,3 +31,12 @@ test('getDrawableActualCurve skips empty design bins and keeps adjacent valid po
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('allows a chart to render when only scatter points are available', () => {
|
||||
assert.equal(hasDrawablePowerCurveData({
|
||||
actualCurve: [],
|
||||
designCurve: [],
|
||||
scatterPoints: [{ wind_speed: 8, active_power: 1500 }],
|
||||
}), true);
|
||||
assert.equal(hasDrawablePowerCurveData({}), false);
|
||||
});
|
||||
|
||||
@@ -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