功能: 完善账号、工作台与功率计算方案

- 新增账号管理、有效期、启停控制与登录工作台布局
- 新增方案三叶轮转速计算及字段映射和报告支持
- 修复功率曲线断线、图例越界及方案一散点绘制
- 优化计算进度、浅色主题和当前方案提示
- 补充前端测试与部署依赖配置
This commit is contained in:
cloud
2026-09-16 15:14:45 +08:00
parent befbf6c029
commit 58be5a34f6
26 changed files with 3357 additions and 297 deletions
+4
View File
@@ -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
)
+446
View File
@@ -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(&timestamp, &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;
}
}
}
+76
View File
@@ -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
+185
View File
@@ -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);
}
+34
View File
@@ -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
+394 -64
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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.
+10
View File
@@ -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

+273
View File
@@ -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; }
}
+97 -6
View File
@@ -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>
);
}
+319 -220
View File
@@ -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}
>
导出有效数据
+477
View File
@@ -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;
}
+56 -3
View File
@@ -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, '');
});