功能: 完善账号、工作台与功率计算方案
- 新增账号管理、有效期、启停控制与登录工作台布局 - 新增方案三叶轮转速计算及字段映射和报告支持 - 修复功率曲线断线、图例越界及方案一散点绘制 - 优化计算进度、浅色主题和当前方案提示 - 补充前端测试与部署依赖配置
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- 六边形 + 内部连接节点:工业网络枢纽 -->
|
||||
<path d="M16 2 L28 9 L28 23 L16 30 L4 23 L4 9 Z" stroke="#6366f1" stroke-width="2" fill="rgba(99,102,241,0.1)"/>
|
||||
<circle cx="16" cy="10" r="2.5" fill="#6366f1"/>
|
||||
<circle cx="10" cy="21" r="2.5" fill="#6366f1"/>
|
||||
<circle cx="22" cy="21" r="2.5" fill="#6366f1"/>
|
||||
<line x1="16" y1="10" x2="10" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<line x1="16" y1="10" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||
<line x1="10" y1="21" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
@@ -20,6 +20,45 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.currentSchemeBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 10px;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.currentSchemeLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.currentSchemeLabel span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.currentSchemeLabel strong {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.currentSchemeBanner p {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.homeHeader h1 {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
@@ -475,6 +514,95 @@ select:focus {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.calculationProgress {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
background: #f5f7ff;
|
||||
}
|
||||
|
||||
.calculationProgress.complete {
|
||||
border-color: #a7f3d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.calculationProgressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calculationProgressHeader span {
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.calculationSteps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.calculationStep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calculationStep i {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calculationStep.active { color: #4338ca; font-weight: 600; }
|
||||
.calculationStep.active i { background: #6366f1; color: #ffffff; }
|
||||
.calculationStep.completed { color: #047857; }
|
||||
.calculationStep.completed i,
|
||||
.calculationProgress.complete .calculationStep i { background: #10b981; color: #ffffff; }
|
||||
|
||||
.calculationProgressTrack {
|
||||
height: 7px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.calculationProgressTrack span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.calculationProgress.complete .calculationProgressTrack span { background: #10b981; }
|
||||
.calculationProgress p { margin: 9px 0 0; color: #475569; font-size: 13px; }
|
||||
|
||||
.calculationTiming {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.headerPreview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -770,6 +898,12 @@ td {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.currentSchemeBanner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldControl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -782,6 +916,8 @@ td {
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calculationSteps { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* ── 浅色主题覆盖 ── */
|
||||
@@ -928,3 +1064,140 @@ td { color: #334155; border-bottom-color: #e6edf5; }
|
||||
@media (max-width: 560px) {
|
||||
.chartSettingsGrid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.authLoading {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.authLoading { color: #64748b; }
|
||||
|
||||
.accountPage h1 { color: #172033; }
|
||||
.accountPageHeader p { margin-top: 6px; color: #64748b; }
|
||||
|
||||
.accountForm label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accountExpiryField {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accountExpiryInputs,
|
||||
.tableExpiryEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accountForm .accountPermanentToggle,
|
||||
.tableExpiryEditor .accountPermanentToggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accountForm .accountPermanentToggle input,
|
||||
.tableExpiryEditor .accountPermanentToggle input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
|
||||
.accountExpiryInputs > input[type='date'] { min-width: 0; }
|
||||
.accountExpiryInputs > input[type='date']:disabled,
|
||||
.tableExpiryEditor .tableInput:disabled { background: #f1f5f9; color: #94a3b8; }
|
||||
.tableExpiryEditor .tableInput { min-width: 138px; }
|
||||
|
||||
.accountActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accountEnableButton,
|
||||
.accountDisableButton {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accountEnableButton { border: 1px solid #86efac; color: #047857; }
|
||||
.accountEnableButton:hover { background: #ecfdf5; }
|
||||
.accountDisableButton { border: 1px solid #fecaca; color: #b91c1c; }
|
||||
.accountDisableButton:hover { background: #fef2f2; }
|
||||
.accountEnableButton:disabled,
|
||||
.accountDisableButton:disabled { cursor: not-allowed; opacity: 0.52; }
|
||||
|
||||
.accountForm input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.accountForm input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.accountPage {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 56px;
|
||||
}
|
||||
|
||||
.accountPageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.accountCreatePanel h2 {
|
||||
margin-bottom: 16px;
|
||||
color: #172033;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.accountForm {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.accountForm { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.accountForm { grid-template-columns: 1fr; }
|
||||
.accountPageHeader { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import AppLayout from './components/AppLayout/AppLayout';
|
||||
import AdminAccountsPage from './pages/AdminAccountsPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { getCurrentUser, logout } from './utils/api';
|
||||
import './App.css';
|
||||
|
||||
function AuthenticatedApp({ user, onLogout }) {
|
||||
const location = useLocation();
|
||||
const activeSection = location.pathname === '/settings'
|
||||
? 'settings'
|
||||
: location.pathname === '/calculation'
|
||||
? 'calculation'
|
||||
: null;
|
||||
|
||||
if (!activeSection && location.pathname !== '/admin/accounts') {
|
||||
return <Navigate to="/settings" replace />;
|
||||
}
|
||||
if (location.pathname === '/admin/accounts' && !user.is_admin) {
|
||||
return <Navigate to="/settings" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout user={user} onLogout={onLogout}>
|
||||
{activeSection
|
||||
? <HomePage activeSection={activeSection} />
|
||||
: <AdminAccountsPage />}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(undefined);
|
||||
const [authMessage, setAuthMessage] = useState('');
|
||||
|
||||
const loadCurrentUser = useCallback(async () => {
|
||||
try {
|
||||
setUser(await getCurrentUser());
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
if (error.message === '账号异常请联系管理员') {
|
||||
setAuthMessage(error.message);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser();
|
||||
}, [loadCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthRequired = (event) => {
|
||||
setUser(null);
|
||||
setAuthMessage(event.detail?.message || '请先登录');
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
window.addEventListener('wind-auth-required', handleAuthRequired);
|
||||
return () => window.removeEventListener('wind-auth-required', handleAuthRequired);
|
||||
}, [navigate]);
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
setAuthMessage('');
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (user === undefined) {
|
||||
return <div className="authLoading">正在检查登录状态…</div>;
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={(
|
||||
<LoginPage
|
||||
initialMessage={authMessage}
|
||||
onLogin={(currentUser) => {
|
||||
setUser(currentUser);
|
||||
setAuthMessage('');
|
||||
navigate('/settings', { replace: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
return <AuthenticatedApp user={user} onLogout={handleLogout} />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* SPA 回退:未匹配路由统一回到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/settings', icon: '⚙', label: '参数设置' },
|
||||
{ to: '/calculation', icon: '📈', label: '风电功率计算' },
|
||||
];
|
||||
|
||||
function UserIcon({ admin }) {
|
||||
if (admin) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout({ children, user, onLogout }) {
|
||||
const navItems = user.is_admin
|
||||
? [...NAV_ITEMS, { to: '/admin/accounts', icon: '👤', label: '账号管理' }]
|
||||
: NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<nav className={styles.sidebar} aria-label="主导航">
|
||||
<div className={styles.brand}>
|
||||
<img src="/logo-1.svg" alt="" className={styles.brandLogo} />
|
||||
<span className={styles.brandText}>风电功率计算平台</span>
|
||||
</div>
|
||||
<div className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `${styles.navItem} ${isActive ? styles.navActive : ''}`}
|
||||
>
|
||||
<span className={styles.navIcon} aria-hidden="true">{item.icon}</span>
|
||||
<span className={styles.navLabel}>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={styles.mainContainer}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerSpacer} />
|
||||
<div className={styles.userInfo}>
|
||||
<div className={`${styles.roleIcon} ${user.is_admin ? styles.adminIcon : ''}`} title={user.is_admin ? '管理员' : '普通账号'}>
|
||||
<UserIcon admin={user.is_admin} />
|
||||
</div>
|
||||
<span className={styles.userName}>{user.username}</span>
|
||||
{user.is_admin && <span className={styles.roleLabel}>管理员</span>}
|
||||
<div className={styles.divider} />
|
||||
<button className={styles.logoutButton} type="button" onClick={onLogout}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.content}>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #dbe3ed;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px 24px;
|
||||
}
|
||||
|
||||
.brandLogo { width: 28px; height: 28px; object-fit: contain; }
|
||||
.brandText { color: #172033; font-size: 14px; font-weight: 700; letter-spacing: 0.2px; white-space: nowrap; }
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.navItem:hover { color: #334155; background: #f1f5f9; }
|
||||
.navActive, .navActive:hover { color: #4f46e5; background: #eef2ff; }
|
||||
.navIcon { width: 20px; color: inherit; font-size: 16px; text-align: center; }
|
||||
.navLabel { font-weight: 500; white-space: nowrap; }
|
||||
|
||||
.mainContainer { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.header {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dbe3ed;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.headerSpacer { flex: 1; }
|
||||
.userInfo { display: flex; align-items: center; gap: 12px; }
|
||||
.roleIcon { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; background: #f1f5f9; color: #64748b; }
|
||||
.adminIcon { color: #4f46e5; background: #eef2ff; }
|
||||
.userName { color: #1e293b; font-size: 14px; font-weight: 500; }
|
||||
.roleLabel { color: #4f46e5; font-size: 12px; }
|
||||
.divider { width: 1px; height: 16px; margin: 0 4px; background: #dbe3ed; }
|
||||
|
||||
.logoutButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logoutButton:hover { color: #dc2626; background: #fef2f2; }
|
||||
.content { flex: 1; overflow: auto; min-width: 0; background: #f4f7fb; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sidebar { width: 64px; }
|
||||
.brand { justify-content: center; padding-right: 8px; padding-left: 8px; }
|
||||
.brandText, .navLabel { display: none; }
|
||||
.navItem { justify-content: center; padding-right: 8px; padding-left: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createAccount, getAccounts, updateAccount } from '../utils/api';
|
||||
|
||||
function formatDateInput(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function defaultExpiry() {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 30);
|
||||
return formatDateInput(date);
|
||||
}
|
||||
|
||||
function defaultAccountForm() {
|
||||
return { username: '', password: '', expires_on: defaultExpiry(), permanent: false };
|
||||
}
|
||||
|
||||
export default function AdminAccountsPage() {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [form, setForm] = useState(defaultAccountForm);
|
||||
const [drafts, setDrafts] = useState({});
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function loadAccounts() {
|
||||
const data = await getAccounts();
|
||||
setAccounts(data.accounts || []);
|
||||
setDrafts(Object.fromEntries((data.accounts || []).map((account) => [account.username, {
|
||||
expires_on: account.expires_on,
|
||||
permanent: account.permanent ?? !account.expires_on,
|
||||
password: '',
|
||||
}])));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts().catch((requestError) => setError(requestError.message || '加载账号失败'));
|
||||
}, []);
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await createAccount({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
expires_on: form.permanent ? '' : form.expires_on,
|
||||
});
|
||||
setMessage(`账号 ${form.username.trim()} 已创建`);
|
||||
setForm(defaultAccountForm());
|
||||
await loadAccounts();
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || '创建账号失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(username) {
|
||||
const draft = drafts[username];
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await updateAccount(username, {
|
||||
expires_on: draft.permanent ? '' : draft.expires_on,
|
||||
...(draft.password ? { password: draft.password } : {}),
|
||||
});
|
||||
setMessage(`账号 ${username} 已更新,原会话已失效`);
|
||||
await loadAccounts();
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || '更新账号失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(account) {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await updateAccount(account.username, {
|
||||
expires_on: account.expires_on,
|
||||
enabled: !account.enabled,
|
||||
});
|
||||
setMessage(`账号 ${account.username} 已${account.enabled ? '停用' : '启用'}`);
|
||||
await loadAccounts();
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || '更新账号状态失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraft(username, field, value) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[username]: { ...current[username], [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="accountPage">
|
||||
<div className="accountPageHeader">
|
||||
<div><h1>账号管理</h1><p>创建账号并设置登录密码和有效期</p></div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{message && <div className="alert info">{message}</div>}
|
||||
|
||||
<section className="panel accountCreatePanel">
|
||||
<h2>新增账号</h2>
|
||||
<form className="accountForm" onSubmit={handleCreate}>
|
||||
<label>
|
||||
<span>账号</span>
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="accountExpiryField">
|
||||
<span>有效期</span>
|
||||
<div className="accountExpiryInputs">
|
||||
<input
|
||||
type="date"
|
||||
value={form.expires_on}
|
||||
disabled={form.permanent}
|
||||
onChange={(event) => setForm({ ...form, expires_on: event.target.value })}
|
||||
/>
|
||||
<label className="accountPermanentToggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.permanent}
|
||||
onChange={(event) => setForm({ ...form, permanent: event.target.checked })}
|
||||
/>
|
||||
永久
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button className="primaryButton" type="submit" disabled={submitting}>新增账号</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panelHeader"><h2>已有账号</h2><span>{accounts.length} 个账号</span></div>
|
||||
<div className="tableWrap">
|
||||
<table>
|
||||
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>有效期</th><th>新密码</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{accounts.map((account) => (
|
||||
<tr key={account.username}>
|
||||
<td>{account.username}</td>
|
||||
<td>{account.is_admin ? '管理员' : '普通账号'}</td>
|
||||
<td>
|
||||
<span className={`confidence ${!account.enabled || account.expired ? 'low' : 'ok'}`}>
|
||||
{!account.enabled ? '已停用' : account.expired ? '已过期' : '正常'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{account.is_admin ? '永久' : (
|
||||
<div className="tableExpiryEditor">
|
||||
<input
|
||||
className="tableInput"
|
||||
type="date"
|
||||
disabled={drafts[account.username]?.permanent}
|
||||
value={drafts[account.username]?.expires_on || ''}
|
||||
onChange={(event) => updateDraft(
|
||||
account.username,
|
||||
'expires_on',
|
||||
event.target.value,
|
||||
)}
|
||||
/>
|
||||
<label className="accountPermanentToggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={drafts[account.username]?.permanent || false}
|
||||
onChange={(event) => updateDraft(
|
||||
account.username,
|
||||
'permanent',
|
||||
event.target.checked,
|
||||
)}
|
||||
/>
|
||||
永久
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{account.is_admin ? '—' : (
|
||||
<input
|
||||
className="tableInput"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="留空则不修改"
|
||||
value={drafts[account.username]?.password || ''}
|
||||
onChange={(event) => updateDraft(
|
||||
account.username,
|
||||
'password',
|
||||
event.target.value,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!account.is_admin && (
|
||||
<div className="accountActions">
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleUpdate(account.username)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className={account.enabled ? 'accountDisableButton' : 'accountEnableButton'}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleToggleEnabled(account)}
|
||||
>
|
||||
{account.enabled ? '停用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteWindJob,
|
||||
downloadWindReport,
|
||||
finishWindJob,
|
||||
getWindFanPoints,
|
||||
getWindChartOptions,
|
||||
getWindSchemes,
|
||||
saveWindChartOptions,
|
||||
@@ -17,33 +18,18 @@ import {
|
||||
alignDesignCurveToActualCurve,
|
||||
buildPowerCurveModel,
|
||||
getDrawableActualCurve,
|
||||
hasDrawablePowerCurveData,
|
||||
} from '../utils/powerCurveModel';
|
||||
import { resolvePowerCurveLegendLayout } from '../utils/powerCurveLegendLayout';
|
||||
import {
|
||||
BASE_REQUIRED_FIELDS,
|
||||
inferWindMapping,
|
||||
normalizeHeader,
|
||||
PITCH_REQUIRED_FIELDS,
|
||||
ROTOR_SPEED_FIELD,
|
||||
} from '../utils/windFieldMapping';
|
||||
|
||||
const REQUIRED_FIELDS = [
|
||||
{ key: 'time', label: '时间' },
|
||||
{ key: 'fan_id', label: '风机编号' },
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
{ key: 'active_power', label: '有功功率' },
|
||||
{ key: 'generator_speed', label: '发电机转速' },
|
||||
{ key: 'blade_pitch_1', label: '1#叶片角度' },
|
||||
{ key: 'blade_pitch_2', label: '2#叶片角度' },
|
||||
{ key: 'blade_pitch_3', label: '3#叶片角度' },
|
||||
];
|
||||
|
||||
const FIELD_HINTS = {
|
||||
time: ['时间', 'time', 'timestamp', '日期'],
|
||||
fan_id: ['风机编号', '风机', '机组编号', 'fan', 'turbine'],
|
||||
wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'],
|
||||
active_power: ['平均有功功率', '有功功率', 'active power', 'power'],
|
||||
generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'],
|
||||
blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'],
|
||||
blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'],
|
||||
blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'],
|
||||
};
|
||||
|
||||
const FIELD_EXCLUDES = {
|
||||
active_power: ['限功率', '限电', '时间', '累计'],
|
||||
};
|
||||
const CALCULATION_STEPS = ['准备数据', '上传数据', '清洗计算', '计算完成'];
|
||||
|
||||
const DESIGN_FIELDS = [
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
@@ -55,13 +41,14 @@ const DESIGN_FIELD_HINTS = {
|
||||
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 4000;
|
||||
const MAX_UPLOAD_CHUNK_BYTES = 6 * 1024 * 1024;
|
||||
const CHART_HEIGHT = 580;
|
||||
const BRUSH_RADIUS = 10;
|
||||
const EDIT_TOOL_RESTORE = 'restore';
|
||||
const EDIT_TOOL_ERASE = 'erase';
|
||||
const DEFAULT_SCHEME_ID = 'scheme_one';
|
||||
const SCHEME_TWO_ID = 'scheme_two';
|
||||
const SCHEME_THREE_ID = 'scheme_three';
|
||||
const DEFAULT_SCHEMES = [
|
||||
{
|
||||
id: 'scheme_one',
|
||||
@@ -93,6 +80,24 @@ const DEFAULT_SCHEMES = [
|
||||
report_wind_speed_interval: 0.25,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SCHEME_THREE_ID,
|
||||
name: '方案三',
|
||||
description: '叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比',
|
||||
parameters: {
|
||||
rated_power: 4800,
|
||||
rated_wind_speed: 14,
|
||||
power_step: 5,
|
||||
cleaning_wind_speed_step: 0.25,
|
||||
wind_speed_change_threshold: 1,
|
||||
iqr_lower_multiplier: 1.2,
|
||||
iqr_upper_multiplier: 2,
|
||||
minimum_generator_speed: 1,
|
||||
generator_speed_k: 0.9,
|
||||
rotor_radius: 78,
|
||||
report_wind_speed_interval: 0.25,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SCHEME_TWO_PARAMS = {
|
||||
@@ -115,6 +120,19 @@ const DEFAULT_SCHEME_ONE_PARAMS = {
|
||||
gearbox_ratio: '162',
|
||||
report_wind_speed_interval: '0.25',
|
||||
};
|
||||
const DEFAULT_SCHEME_THREE_PARAMS = {
|
||||
rated_power: '4800',
|
||||
rated_wind_speed: '14',
|
||||
power_step: '5',
|
||||
cleaning_wind_speed_step: '0.25',
|
||||
wind_speed_change_threshold: '1',
|
||||
iqr_lower_multiplier: '1.2',
|
||||
iqr_upper_multiplier: '2',
|
||||
minimum_generator_speed: '1',
|
||||
generator_speed_k: '0.9',
|
||||
rotor_radius: '78',
|
||||
report_wind_speed_interval: '0.25',
|
||||
};
|
||||
const SCHEME_ONE_PARAM_FIELDS = [
|
||||
{ key: 'rated_power', label: '额定功率', unit: 'kW', min: '0', step: '0.1' },
|
||||
{ key: 'rated_wind_speed', label: '额定风速', unit: 'm/s', min: '0', step: '0.01' },
|
||||
@@ -129,6 +147,9 @@ const SCHEME_ONE_PARAM_FIELDS = [
|
||||
{ key: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' },
|
||||
{ key: 'report_wind_speed_interval', label: '报告公式风速区间半宽', unit: 'm/s', min: '0', step: '0.01' },
|
||||
];
|
||||
const SCHEME_THREE_PARAM_FIELDS = SCHEME_ONE_PARAM_FIELDS.filter(
|
||||
(field) => field.key !== 'gearbox_ratio',
|
||||
);
|
||||
const CHART_STATE_KEY = 'wind_power_chart_state_v1';
|
||||
const DEFAULT_CHART_OPTIONS = {
|
||||
title: '#机组功率曲线',
|
||||
@@ -206,50 +227,6 @@ function createDesignRows(points) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[()()_\-./]/g, '');
|
||||
}
|
||||
|
||||
function inferMapping(headers) {
|
||||
const normalized = headers.map((header) => ({
|
||||
header,
|
||||
normalized: normalizeHeader(header),
|
||||
}));
|
||||
const mapping = {};
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
const hints = FIELD_HINTS[field.key].map(normalizeHeader);
|
||||
const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader);
|
||||
let best = null;
|
||||
for (const item of normalized) {
|
||||
if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) {
|
||||
continue;
|
||||
}
|
||||
let score = 0;
|
||||
hints.forEach((hint, index) => {
|
||||
const weight = hints.length - index;
|
||||
if (item.normalized === hint) {
|
||||
score = Math.max(score, 100 + weight);
|
||||
} else if (item.normalized.includes(hint)) {
|
||||
score = Math.max(score, 60 + weight);
|
||||
} else if (hint.includes(item.normalized)) {
|
||||
score = Math.max(score, 20 + weight);
|
||||
}
|
||||
});
|
||||
if (!best || score > best.score) {
|
||||
best = { ...item, score };
|
||||
}
|
||||
}
|
||||
mapping[field.key] = best && best.score > 0 ? best.header : '';
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function inferDesignMapping(headers) {
|
||||
const normalized = headers.map((header) => ({
|
||||
header,
|
||||
@@ -285,57 +262,10 @@ function inferDesignMapping(headers) {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function excelSerialToDate(value) {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return null;
|
||||
}
|
||||
const utcDays = Math.floor(days - 25569);
|
||||
const utcValue = utcDays * 86400;
|
||||
const dateInfo = new Date(utcValue * 1000);
|
||||
const fractionalDay = days - Math.floor(days) + 0.0000001;
|
||||
const totalSeconds = Math.floor(86400 * fractionalDay);
|
||||
dateInfo.setSeconds(totalSeconds);
|
||||
return dateInfo;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function normalizeTime(value) {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
return formatDate(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
const date = excelSerialToDate(value);
|
||||
return date ? formatDate(date) : '';
|
||||
}
|
||||
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const isoMatch = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||||
if (isoMatch) {
|
||||
const [, year, month, day, hour, minute, second = '0'] = isoMatch;
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||||
}
|
||||
|
||||
const slashMatch = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||||
if (slashMatch) {
|
||||
const [, month, day, rawYear, hour, minute, second = '0'] = slashMatch;
|
||||
const year = rawYear.length === 2 ? `20${rawYear}` : rawYear;
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
@@ -439,38 +369,35 @@ function getCell(row, headerIndex) {
|
||||
return row[headerIndex];
|
||||
}
|
||||
|
||||
function buildStandardRows(files, mapping) {
|
||||
const firstHeaders = files[0]?.headers || [];
|
||||
const indexes = Object.fromEntries(
|
||||
REQUIRED_FIELDS.map((field) => [field.key, firstHeaders.indexOf(mapping[field.key])]),
|
||||
);
|
||||
|
||||
const rows = [];
|
||||
function buildRawRows(files) {
|
||||
const rawRows = [];
|
||||
for (const file of files) {
|
||||
const localIndexes = Object.fromEntries(
|
||||
REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]),
|
||||
);
|
||||
for (let rowIndex = 0; rowIndex < file.rows.length; rowIndex += 1) {
|
||||
const row = file.rows[rowIndex];
|
||||
rows.push({
|
||||
time: normalizeTime(getCell(row, localIndexes.time)),
|
||||
fan_id: String(getCell(row, localIndexes.fan_id) ?? '').trim(),
|
||||
wind_speed: normalizeNumber(getCell(row, localIndexes.wind_speed)),
|
||||
active_power: normalizeNumber(getCell(row, localIndexes.active_power)),
|
||||
generator_speed: normalizeNumber(getCell(row, localIndexes.generator_speed)),
|
||||
blade_pitch_1: normalizeNumber(getCell(row, localIndexes.blade_pitch_1)),
|
||||
blade_pitch_2: normalizeNumber(getCell(row, localIndexes.blade_pitch_2)),
|
||||
blade_pitch_3: normalizeNumber(getCell(row, localIndexes.blade_pitch_3)),
|
||||
});
|
||||
rawRows.push({
|
||||
file_name: file.file_name,
|
||||
values: [...(file.raw_rows?.[rowIndex] || row)],
|
||||
values: [...(file.raw_rows?.[rowIndex] || file.rows[rowIndex])],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rawRows;
|
||||
}
|
||||
|
||||
return { rows, rawRows, indexes };
|
||||
function splitRawRowsIntoChunks(rawRows) {
|
||||
const chunks = [];
|
||||
let chunk = [];
|
||||
let size = 0;
|
||||
for (const row of rawRows) {
|
||||
const rowSize = JSON.stringify(row).length;
|
||||
if (chunk.length && size + rowSize > MAX_UPLOAD_CHUNK_BYTES) {
|
||||
chunks.push(chunk);
|
||||
chunk = [];
|
||||
size = 0;
|
||||
}
|
||||
chunk.push(row);
|
||||
size += rowSize;
|
||||
}
|
||||
if (chunk.length) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function hasMatchingHeaderSequence(files) {
|
||||
@@ -491,12 +418,13 @@ function sanitizeFileName(value) {
|
||||
return String(value || '未选择风机').replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
function exportValidRowsToExcel(fanId, rows) {
|
||||
function exportValidRowsToExcel(fanId, rows, includeRotorSpeed = false) {
|
||||
const exportRows = (rows || []).map((row) => ({
|
||||
风机编号: row.fan_id || fanId || '',
|
||||
采样时间: row.time || '',
|
||||
平均功率: row.active_power ?? '',
|
||||
平均转速: row.generator_speed ?? '',
|
||||
...(includeRotorSpeed ? { 叶轮转速: row.rotor_speed ?? '' } : {}),
|
||||
平均风速: row.wind_speed ?? '',
|
||||
'3个叶片变桨角平均值': row.pitch_angle_average ?? '',
|
||||
}));
|
||||
@@ -603,6 +531,10 @@ function schemeParamsToState(parameters) {
|
||||
parameters?.rated_generator_speed ?? DEFAULT_SCHEME_TWO_PARAMS.rated_generator_speed,
|
||||
),
|
||||
rated_power: String(parameters?.rated_power ?? DEFAULT_SCHEME_TWO_PARAMS.rated_power),
|
||||
report_wind_speed_interval: String(
|
||||
parameters?.report_wind_speed_interval
|
||||
?? DEFAULT_SCHEME_TWO_PARAMS.report_wind_speed_interval,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -613,6 +545,37 @@ function schemeOneParamsToState(parameters) {
|
||||
]));
|
||||
}
|
||||
|
||||
function schemeThreeParamsToState(parameters) {
|
||||
return Object.fromEntries(Object.entries(DEFAULT_SCHEME_THREE_PARAMS).map(([field, fallback]) => [
|
||||
field,
|
||||
String(parameters?.[field] ?? fallback),
|
||||
]));
|
||||
}
|
||||
|
||||
function normalizeParams(params) {
|
||||
return Object.fromEntries(Object.entries(params).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
}
|
||||
|
||||
function areBaseSchemeParamsInvalid(values, requireGearboxRatio) {
|
||||
return !Number.isFinite(values.rated_power) || values.rated_power <= 0 ||
|
||||
!Number.isFinite(values.rated_wind_speed) || values.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(values.power_step) || values.power_step <= 0 ||
|
||||
!Number.isFinite(values.cleaning_wind_speed_step) ||
|
||||
values.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(values.rotor_radius) || values.rotor_radius <= 0 ||
|
||||
(requireGearboxRatio &&
|
||||
(!Number.isFinite(values.gearbox_ratio) || values.gearbox_ratio <= 0)) ||
|
||||
!Number.isFinite(values.report_wind_speed_interval) ||
|
||||
values.report_wind_speed_interval <= 0 || values.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(values[field]) || values[field] < 0
|
||||
));
|
||||
}
|
||||
|
||||
function SummaryCards({ summary }) {
|
||||
if (!summary) return null;
|
||||
const cards = [
|
||||
@@ -930,28 +893,44 @@ function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
];
|
||||
context.textAlign = 'left';
|
||||
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
|
||||
const legendLeft = padding.left + 22;
|
||||
const legendTop = padding.top + Math.max(30, typography.legend + 10);
|
||||
const legendLineHeight = Math.max(34, typography.legend + 12);
|
||||
const legendLayout = resolvePowerCurveLegendLayout({
|
||||
plotLeft: padding.left,
|
||||
plotTop: padding.top,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
fontSize: typography.legend,
|
||||
entries,
|
||||
textWidths: entries.map(([label]) => context.measureText(label).width),
|
||||
});
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(padding.left, padding.top, plotWidth, plotHeight);
|
||||
context.clip();
|
||||
entries.forEach(([label, type], index) => {
|
||||
const top = legendTop + index * legendLineHeight;
|
||||
const top = legendLayout.top + index * legendLayout.lineHeight;
|
||||
const markerY = top + legendLayout.textHeight / 2;
|
||||
const textBaseline = top + typography.legend;
|
||||
const isScatter = type === 'scatter';
|
||||
const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null;
|
||||
if (isScatter) {
|
||||
drawMarker(context, legendLeft + 10, top, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity));
|
||||
drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity));
|
||||
} else if (style) {
|
||||
context.save();
|
||||
context.strokeStyle = style.color;
|
||||
context.lineWidth = 3;
|
||||
context.setLineDash(getLineDash(style.lineStyle));
|
||||
context.beginPath(); context.moveTo(legendLeft, top); context.lineTo(legendLeft + 20, top); context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(legendLayout.left, markerY);
|
||||
context.lineTo(legendLayout.left + legendLayout.markerWidth, markerY);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
} else {
|
||||
drawMarker(context, legendLeft + 10, top, 'circle', 4, type);
|
||||
drawMarker(context, legendLayout.left + legendLayout.markerWidth / 2, markerY, 'circle', 4, type);
|
||||
}
|
||||
context.fillStyle = textColor;
|
||||
context.fillText(label, legendLeft + 30, top + 7);
|
||||
context.fillText(label, legendLayout.left + legendLayout.labelOffset, textBaseline);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
@@ -1288,7 +1267,14 @@ function PowerCurveChart({
|
||||
chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current());
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined;
|
||||
const hasDrawableData = hasDrawablePowerCurveData({
|
||||
actualCurve: visibleActualCurve,
|
||||
designCurve: visibleDesignCurve,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showFiltered,
|
||||
});
|
||||
if (!chartRef.current || !hasDrawableData) return undefined;
|
||||
|
||||
const transientErasedPointIds = new Set();
|
||||
let redrawFrameId = 0;
|
||||
@@ -1628,7 +1614,13 @@ function PowerCurveChart({
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
if (!actualCurve.length && !validDesign.length) {
|
||||
if (!hasDrawablePowerCurveData({
|
||||
actualCurve,
|
||||
designCurve: validDesign,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showFiltered,
|
||||
})) {
|
||||
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
||||
}
|
||||
|
||||
@@ -1673,13 +1665,17 @@ function PowerCurveChart({
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
export default function HomePage({ activeSection = 'settings' }) {
|
||||
const savedSessionState = useMemo(() => loadChartState(), []);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [reading, setReading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [progressStage, setProgressStage] = useState(-1);
|
||||
const [performanceTimings, setPerformanceTimings] = useState(null);
|
||||
const [loadingFanPoints, setLoadingFanPoints] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(() => savedSessionState.result || null);
|
||||
const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || '');
|
||||
@@ -1724,11 +1720,15 @@ export default function HomePage() {
|
||||
const [schemeMessage, setSchemeMessage] = useState('');
|
||||
const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS);
|
||||
const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS);
|
||||
const [schemeThreeParams, setSchemeThreeParams] = useState(DEFAULT_SCHEME_THREE_PARAMS);
|
||||
|
||||
const headers = files[0]?.headers || [];
|
||||
const selectedScheme = schemes.find((scheme) => scheme.id === selectedSchemeId) || schemes[0];
|
||||
const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID;
|
||||
const mappingFields = isSchemeTwo ? REQUIRED_FIELDS : REQUIRED_FIELDS.slice(0, 5);
|
||||
const isSchemeThree = selectedSchemeId === SCHEME_THREE_ID;
|
||||
const mappingFields = isSchemeTwo
|
||||
? [...BASE_REQUIRED_FIELDS, ...PITCH_REQUIRED_FIELDS]
|
||||
: (isSchemeThree ? [...BASE_REQUIRED_FIELDS, ROTOR_SPEED_FIELD] : BASE_REQUIRED_FIELDS);
|
||||
const missingFields = mappingFields.filter((field) => !mapping[field.key]);
|
||||
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
|
||||
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
|
||||
@@ -1797,6 +1797,28 @@ export default function HomePage() {
|
||||
? result.estimated_params[selectedFan]
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!result?.job_id || !selectedFan || result?.scatter_points?.[selectedFan]) return;
|
||||
let cancelled = false;
|
||||
setLoadingFanPoints(true);
|
||||
getWindFanPoints(result.job_id, selectedFan)
|
||||
.then((points) => {
|
||||
if (cancelled) return;
|
||||
setResult((current) => ({
|
||||
...current,
|
||||
scatter_points: { ...(current.scatter_points || {}), [selectedFan]: points.scatter_points || [] },
|
||||
filtered_points: { ...(current.filtered_points || {}), [selectedFan]: points.filtered_points || [] },
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || '加载风机点数据失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingFanPoints(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [result?.job_id, result?.scatter_points, selectedFan]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({
|
||||
@@ -2030,7 +2052,7 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
setExportingReport(true);
|
||||
setReportExportProgress('正在服务器生成完整报告');
|
||||
setReportExportProgress('正在生成报告图表');
|
||||
setError('');
|
||||
try {
|
||||
const chartRenderSnapshot = await chartSnapshotRef.current?.(false);
|
||||
@@ -2038,13 +2060,19 @@ export default function HomePage() {
|
||||
throw new Error('图表尚未完成绘制,请稍后重试');
|
||||
}
|
||||
const chartImageData = renderPowerCurvePng(chartRenderSnapshot);
|
||||
const blob = await downloadWindReport(result.job_id, {
|
||||
setReportExportProgress('正在上传图表和编辑结果');
|
||||
const report = await downloadWindReport(result.job_id, {
|
||||
fan_id: selectedFan,
|
||||
effective_rows: effectiveScatter,
|
||||
report_rows: reportCurveRows,
|
||||
restored_point_ids: restoredPointIds[selectedFan] || [],
|
||||
erased_point_ids: erasedPointIds[selectedFan] || [],
|
||||
chart_image: chartImageData || '',
|
||||
});
|
||||
downloadReportBlob(blob, selectedFan);
|
||||
setReportExportProgress('正在下载完整报告');
|
||||
downloadReportBlob(report.blob, selectedFan);
|
||||
if (report.serverTiming) {
|
||||
setReportExportNotice(`报告已生成(${report.serverTiming})`);
|
||||
}
|
||||
setExportingReport(false);
|
||||
setReportExportProgress('');
|
||||
} catch (err) {
|
||||
@@ -2056,12 +2084,13 @@ export default function HomePage() {
|
||||
}
|
||||
}, [
|
||||
canExportFullReport,
|
||||
effectiveScatter,
|
||||
exportingReport,
|
||||
reportExportDisabledReason,
|
||||
reportCurveRows,
|
||||
selectedFan,
|
||||
result?.job_id,
|
||||
restoredPointIds,
|
||||
erasedPointIds,
|
||||
]);
|
||||
|
||||
const handleCancelReportExport = useCallback(() => {
|
||||
@@ -2090,6 +2119,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters));
|
||||
const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID);
|
||||
setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters));
|
||||
const schemeThree = loadedSchemes.find((scheme) => scheme.id === SCHEME_THREE_ID);
|
||||
setSchemeThreeParams(schemeThreeParamsToState(schemeThree?.parameters));
|
||||
} catch (err) {
|
||||
if (!canceled) {
|
||||
setSchemeMessage(err.message || '读取方案配置失败,已使用默认方案');
|
||||
@@ -2111,6 +2142,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters));
|
||||
} else if (value === SCHEME_TWO_ID) {
|
||||
setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters));
|
||||
} else if (value === SCHEME_THREE_ID) {
|
||||
setSchemeThreeParams(schemeThreeParamsToState(nextScheme?.parameters));
|
||||
}
|
||||
setSchemeMessage('');
|
||||
}
|
||||
@@ -2129,6 +2162,13 @@ export default function HomePage() {
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSchemeThreeParamChange(field, value) {
|
||||
setSchemeThreeParams((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSaveSchemeDescription() {
|
||||
if (!selectedScheme) return;
|
||||
|
||||
@@ -2136,30 +2176,20 @@ export default function HomePage() {
|
||||
setError('');
|
||||
setSchemeMessage('');
|
||||
try {
|
||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||
const reportWindSpeedInterval = normalizeNumber(schemeTwoParams.report_wind_speed_interval);
|
||||
if (selectedScheme.id === DEFAULT_SCHEME_ID &&
|
||||
(!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.cleaning_wind_speed_step) || schemeOneValues.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.report_wind_speed_interval) ||
|
||||
schemeOneValues.report_wind_speed_interval <= 0 ||
|
||||
schemeOneValues.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
|
||||
)))) {
|
||||
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||
throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||
}
|
||||
if (selectedScheme.id === SCHEME_THREE_ID &&
|
||||
areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||
throw new Error('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||
}
|
||||
if (selectedScheme.id === SCHEME_TWO_ID &&
|
||||
(!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 ||
|
||||
!Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 ||
|
||||
@@ -2171,6 +2201,7 @@ export default function HomePage() {
|
||||
const data = await saveWindSchemeDescription(selectedScheme.id, {
|
||||
description: schemeDescription,
|
||||
...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}),
|
||||
...(selectedScheme.id === SCHEME_THREE_ID ? { parameters: schemeThreeValues } : {}),
|
||||
...(selectedScheme.id === SCHEME_TWO_ID ? {
|
||||
parameters: {
|
||||
grid_connected_speed: gridConnectedSpeed,
|
||||
@@ -2189,6 +2220,8 @@ export default function HomePage() {
|
||||
setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters));
|
||||
} else if (savedScheme.id === SCHEME_TWO_ID) {
|
||||
setSchemeTwoParams(schemeParamsToState(savedScheme.parameters));
|
||||
} else if (savedScheme.id === SCHEME_THREE_ID) {
|
||||
setSchemeThreeParams(schemeThreeParamsToState(savedScheme.parameters));
|
||||
}
|
||||
setSchemeMessage('已保存');
|
||||
} catch (err) {
|
||||
@@ -2205,6 +2238,10 @@ export default function HomePage() {
|
||||
setReading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setProgress('');
|
||||
setProgressPercent(0);
|
||||
setProgressStage(-1);
|
||||
setPerformanceTimings(null);
|
||||
setSelectedFan('');
|
||||
setShowFiltered(false);
|
||||
setIsEditMode(false);
|
||||
@@ -2221,7 +2258,7 @@ export default function HomePage() {
|
||||
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
|
||||
}
|
||||
setFiles(parsedFiles);
|
||||
setMapping(inferMapping(parsedFiles[0].headers));
|
||||
setMapping(inferWindMapping(parsedFiles[0].headers));
|
||||
} catch (err) {
|
||||
setError(err.message || '读取 Excel 失败');
|
||||
} finally {
|
||||
@@ -2285,29 +2322,17 @@ export default function HomePage() {
|
||||
setError('请先上传文件并完成所有字段映射');
|
||||
return;
|
||||
}
|
||||
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
|
||||
field,
|
||||
normalizeNumber(value),
|
||||
]));
|
||||
const schemeOneInvalid =
|
||||
!Number.isFinite(schemeOneValues.rated_power) || schemeOneValues.rated_power <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rated_wind_speed) || schemeOneValues.rated_wind_speed <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.power_step) || schemeOneValues.power_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.cleaning_wind_speed_step) ||
|
||||
schemeOneValues.cleaning_wind_speed_step <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.rotor_radius) || schemeOneValues.rotor_radius <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.gearbox_ratio) || schemeOneValues.gearbox_ratio <= 0 ||
|
||||
!Number.isFinite(schemeOneValues.report_wind_speed_interval) ||
|
||||
schemeOneValues.report_wind_speed_interval <= 0 ||
|
||||
schemeOneValues.report_wind_speed_interval > 2 ||
|
||||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
|
||||
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
|
||||
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
|
||||
));
|
||||
if (!isSchemeTwo && schemeOneInvalid) {
|
||||
const schemeOneValues = normalizeParams(schemeOneParams);
|
||||
const schemeThreeValues = normalizeParams(schemeThreeParams);
|
||||
if (!isSchemeTwo && !isSchemeThree &&
|
||||
areBaseSchemeParamsInvalid(schemeOneValues, true)) {
|
||||
setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
|
||||
return;
|
||||
}
|
||||
if (isSchemeThree && areBaseSchemeParamsInvalid(schemeThreeValues, false)) {
|
||||
setError('方案三参数必须为合法数值,额定与步长参数、叶轮半径必须大于 0');
|
||||
return;
|
||||
}
|
||||
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
|
||||
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
|
||||
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
|
||||
@@ -2332,10 +2357,17 @@ export default function HomePage() {
|
||||
setRestoredPointIds({});
|
||||
setErasedPointIds({});
|
||||
setConfirmedCurveScatterByFan({});
|
||||
setPerformanceTimings(null);
|
||||
setProgress('正在准备标准化数据');
|
||||
setProgressPercent(5);
|
||||
setProgressStage(0);
|
||||
|
||||
try {
|
||||
const { rows, rawRows } = buildStandardRows(files, mapping);
|
||||
const totalStartedAt = performance.now();
|
||||
const rawRows = buildRawRows(files);
|
||||
const chunks = splitRawRowsIntoChunks(rawRows);
|
||||
setProgress('正在创建计算任务');
|
||||
setProgressPercent(10);
|
||||
const start = await startWindJob({
|
||||
files: files.map((file) => ({
|
||||
file_name: file.file_name,
|
||||
@@ -2346,25 +2378,29 @@ export default function HomePage() {
|
||||
});
|
||||
jobId = start.job_id;
|
||||
|
||||
const chunkCount = Math.ceil(rows.length / CHUNK_SIZE);
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunkRows = rows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE);
|
||||
setProgress(`正在上传数据分片 ${index + 1}/${chunkCount}`);
|
||||
const uploadStartedAt = performance.now();
|
||||
setProgressStage(1);
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
setProgress(`正在上传原始数据分片 ${index + 1}/${chunks.length}`);
|
||||
setProgressPercent(15 + Math.round((index / chunks.length) * 55));
|
||||
await uploadWindChunk({
|
||||
job_id: jobId,
|
||||
chunk_index: index,
|
||||
rows: chunkRows,
|
||||
raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE),
|
||||
raw_rows: chunks[index],
|
||||
});
|
||||
setProgressPercent(15 + Math.round(((index + 1) / chunks.length) * 55));
|
||||
}
|
||||
|
||||
setProgress('正在清洗数据并计算功率曲线');
|
||||
setProgressPercent(75);
|
||||
setProgressStage(2);
|
||||
const calculation = await finishWindJob({
|
||||
job_id: jobId,
|
||||
options: {
|
||||
curve_wind_speed_step: 0.5,
|
||||
scheme_id: selectedSchemeId,
|
||||
...(!isSchemeTwo ? schemeOneValues : {}),
|
||||
...(!isSchemeTwo && !isSchemeThree ? schemeOneValues : {}),
|
||||
...(isSchemeThree ? schemeThreeValues : {}),
|
||||
...(isSchemeTwo ? {
|
||||
grid_connected_speed: gridConnectedSpeed,
|
||||
rated_generator_speed: ratedGeneratorSpeed,
|
||||
@@ -2373,9 +2409,17 @@ export default function HomePage() {
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
const initialFan = calculation.initial_fan_id || calculation.fans?.[0] || '';
|
||||
setResult({ ...calculation, job_id: jobId });
|
||||
setSelectedFan(calculation.fans?.[0] || '');
|
||||
setSelectedFan(initialFan);
|
||||
setPerformanceTimings({
|
||||
...(calculation.timings_ms || {}),
|
||||
upload: Math.round(performance.now() - uploadStartedAt),
|
||||
total: Math.round(performance.now() - totalStartedAt),
|
||||
});
|
||||
setProgress('计算完成');
|
||||
setProgressPercent(100);
|
||||
setProgressStage(3);
|
||||
} catch (err) {
|
||||
if (jobId) {
|
||||
try {
|
||||
@@ -2390,6 +2434,8 @@ export default function HomePage() {
|
||||
setReportExportNotice(message);
|
||||
}
|
||||
setProgress('');
|
||||
setProgressPercent(0);
|
||||
setProgressStage(-1);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -2399,10 +2445,14 @@ export default function HomePage() {
|
||||
<div className="home">
|
||||
<header className="homeHeader">
|
||||
<div>
|
||||
<h1>风电功率计算平台</h1>
|
||||
<p className="subtitle">多 Excel 导入、字段映射、数据清洗与功率曲线计算</p>
|
||||
<h1>{activeSection === 'settings' ? '参数设置' : '风电功率计算'}</h1>
|
||||
<p className="subtitle">
|
||||
{activeSection === 'settings'
|
||||
? '配置计算方案与设计功率曲线'
|
||||
: '多 Excel 导入、字段映射、数据清洗与功率曲线计算'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="headerActions">
|
||||
{activeSection === 'calculation' && <div className="headerActions">
|
||||
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="file"
|
||||
@@ -2413,11 +2463,19 @@ export default function HomePage() {
|
||||
/>
|
||||
{reading ? '解析中' : '上传 Excel'}
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
</header>
|
||||
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{progress && <div className="alert info">{progress}</div>}
|
||||
{activeSection === 'calculation' && (
|
||||
<section className="currentSchemeBanner" aria-label="当前使用的计算方案">
|
||||
<div className="currentSchemeLabel">
|
||||
<span>当前使用方案</span>
|
||||
<strong>{selectedScheme?.name || '方案一'}</strong>
|
||||
</div>
|
||||
<p>{schemeDescription || selectedScheme?.description || '暂无方案描述'}</p>
|
||||
</section>
|
||||
)}
|
||||
{reportExportNotice && (
|
||||
<div className="dialogBackdrop" role="presentation">
|
||||
<section className="appDialog" role="dialog" aria-modal="true" aria-labelledby="report-export-notice-title">
|
||||
@@ -2432,7 +2490,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel schemePanel">
|
||||
<section className="panel schemePanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>计算方案</h2>
|
||||
@@ -2469,18 +2527,24 @@ export default function HomePage() {
|
||||
{!isSchemeTwo && (
|
||||
<>
|
||||
<p className="schemeParamHint">
|
||||
叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速
|
||||
{isSchemeThree
|
||||
? '叶尖速比 = 叶轮转速 × 3.14 × 叶轮半径 × 30 ÷ 风速'
|
||||
: '叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速'}
|
||||
</p>
|
||||
<div className="schemeParamGrid">
|
||||
{SCHEME_ONE_PARAM_FIELDS.map((field) => (
|
||||
{(isSchemeThree ? SCHEME_THREE_PARAM_FIELDS : SCHEME_ONE_PARAM_FIELDS).map((field) => (
|
||||
<label className="fieldControl" key={field.key}>
|
||||
<span>{field.label}{field.unit ? ` (${field.unit})` : ''}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={field.min}
|
||||
step={field.step}
|
||||
value={schemeOneParams[field.key]}
|
||||
onChange={(event) => handleSchemeOneParamChange(field.key, event.target.value)}
|
||||
value={(isSchemeThree ? schemeThreeParams : schemeOneParams)[field.key]}
|
||||
onChange={(event) => (
|
||||
isSchemeThree
|
||||
? handleSchemeThreeParamChange(field.key, event.target.value)
|
||||
: handleSchemeOneParamChange(field.key, event.target.value)
|
||||
)}
|
||||
disabled={submitting || savingScheme}
|
||||
placeholder="请输入"
|
||||
/>
|
||||
@@ -2567,7 +2631,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel designPowerPanel">
|
||||
<section className="panel designPowerPanel" style={{ display: activeSection === 'settings' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>设计功率曲线</h2>
|
||||
@@ -2650,7 +2714,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="workspace">
|
||||
<section className="workspace" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>1. 数据文件</h2>
|
||||
@@ -2699,6 +2763,36 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{progress && (
|
||||
<div className={`calculationProgress ${progressStage === 3 ? 'complete' : ''}`} role="status" aria-live="polite">
|
||||
<div className="calculationProgressHeader">
|
||||
<strong>计算进度</strong>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="calculationSteps" aria-label="计算步骤">
|
||||
{CALCULATION_STEPS.map((step, index) => (
|
||||
<div
|
||||
className={`calculationStep ${index < progressStage ? 'completed' : ''} ${index === progressStage ? 'active' : ''}`}
|
||||
key={step}
|
||||
>
|
||||
<i>{index < progressStage || progressStage === 3 ? '✓' : index + 1}</i>
|
||||
<span>{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="calculationProgressTrack" aria-hidden="true">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
<p>{progress}</p>
|
||||
</div>
|
||||
)}
|
||||
{performanceTimings && (
|
||||
<div className="calculationTiming">
|
||||
上传 {performanceTimings.upload ?? 0} ms · 服务端计算 {performanceTimings.calculation ?? 0} ms
|
||||
(读取 {performanceTimings.read_parse ?? 0} ms,清洗 {performanceTimings.cleaning_and_points ?? 0} ms)
|
||||
· 总计 {performanceTimings.total ?? 0} ms
|
||||
</div>
|
||||
)}
|
||||
<div className="actionRow">
|
||||
<button
|
||||
className="primaryButton"
|
||||
@@ -2712,7 +2806,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<section className="panel" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panelHeader">
|
||||
<h2>3. 列头预览</h2>
|
||||
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
||||
@@ -2729,7 +2823,7 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{result && (
|
||||
<section className="resultSection">
|
||||
<section className="resultSection" style={{ display: activeSection === 'calculation' ? undefined : 'none' }}>
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>4. 清洗结果</h2>
|
||||
@@ -2758,6 +2852,7 @@ export default function HomePage() {
|
||||
<span>
|
||||
曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点
|
||||
· 滤除 {visibleFilteredPoints.length.toLocaleString()} 点
|
||||
{loadingFanPoints ? ' · 正在加载点数据…' : ''}
|
||||
</span>
|
||||
<button
|
||||
className="secondaryButton"
|
||||
@@ -2807,7 +2902,11 @@ export default function HomePage() {
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
|
||||
onClick={() => exportValidRowsToExcel(
|
||||
selectedFan,
|
||||
effectiveScatter,
|
||||
isSchemeThree,
|
||||
)}
|
||||
disabled={!effectiveScatter.length}
|
||||
>
|
||||
导出有效数据
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { login } from '../utils/api';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
// ─── Simplex-like 噪声(轻量实现) ─────────────────────────
|
||||
function createNoise() {
|
||||
const perm = new Uint8Array(512);
|
||||
const p = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) p[i] = i;
|
||||
for (let i = 255; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[p[i], p[j]] = [p[j], p[i]];
|
||||
}
|
||||
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||||
|
||||
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||||
function lerp(a, b, t) { return a + t * (b - a); }
|
||||
function grad(hash, x, y) {
|
||||
const h = hash & 3;
|
||||
const u = h < 2 ? x : y;
|
||||
const v = h < 2 ? y : x;
|
||||
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||||
}
|
||||
|
||||
return function noise2D(x, y) {
|
||||
const X = Math.floor(x) & 255;
|
||||
const Y = Math.floor(y) & 255;
|
||||
const xf = x - Math.floor(x);
|
||||
const yf = y - Math.floor(y);
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
const a = perm[X] + Y;
|
||||
const b = perm[X + 1] + Y;
|
||||
return lerp(
|
||||
lerp(grad(perm[a], xf, yf), grad(perm[b], xf - 1, yf), u),
|
||||
lerp(grad(perm[a + 1], xf, yf - 1), grad(perm[b + 1], xf - 1, yf - 1), u),
|
||||
v
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 宇宙深空 Canvas ──────────────────────────────────────
|
||||
function DeepSpaceCanvas() {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const noise = createNoise();
|
||||
let animId;
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ── 三层星星 ──
|
||||
const farStars = Array.from({ length: 500 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
r: 0.3 + Math.random() * 0.7,
|
||||
alpha: 0.15 + Math.random() * 0.25,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.05 + Math.random() * 0.15,
|
||||
}));
|
||||
|
||||
const midStars = Array.from({ length: 120 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
r: 0.6 + Math.random() * 1.2,
|
||||
alpha: 0.3 + Math.random() * 0.4,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.15 + Math.random() * 0.3,
|
||||
hue: Math.random() > 0.7 ? 30 + Math.random() * 30 : 210 + Math.random() * 50,
|
||||
}));
|
||||
|
||||
const nearStars = Array.from({ length: 35 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
r: 1.2 + Math.random() * 2,
|
||||
alpha: 0.5 + Math.random() * 0.5,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.2 + Math.random() * 0.5,
|
||||
hue: Math.random() > 0.6 ? 20 + Math.random() * 40 : 200 + Math.random() * 60,
|
||||
}));
|
||||
|
||||
// ── 星尘带(密集微粒模拟银河带) ──
|
||||
const starDust = Array.from({ length: 800 }, () => {
|
||||
const band = 0.3 + Math.random() * 0.4; // 集中在中间带
|
||||
return {
|
||||
x: Math.random(),
|
||||
y: band + (Math.random() - 0.5) * 0.25,
|
||||
r: 0.2 + Math.random() * 0.5,
|
||||
alpha: 0.06 + Math.random() * 0.12,
|
||||
};
|
||||
});
|
||||
|
||||
// ── 流星 ──
|
||||
let meteors = [];
|
||||
function spawnMeteor() {
|
||||
meteors.push({
|
||||
x: Math.random() * 0.8 + 0.1,
|
||||
y: Math.random() * 0.3,
|
||||
vx: 0.003 + Math.random() * 0.004,
|
||||
vy: 0.002 + Math.random() * 0.003,
|
||||
life: 1,
|
||||
len: 60 + Math.random() * 80,
|
||||
hue: 200 + Math.random() * 40,
|
||||
});
|
||||
}
|
||||
|
||||
// 星云离屏缓冲(低分辨率)
|
||||
const nebulaCanvas = document.createElement('canvas');
|
||||
const nebulaCtx = nebulaCanvas.getContext('2d');
|
||||
const NEBULA_SCALE = 6;
|
||||
let lastNebulaTime = -1;
|
||||
|
||||
function drawNebula(t) {
|
||||
const nw = Math.ceil(canvas.width / NEBULA_SCALE);
|
||||
const nh = Math.ceil(canvas.height / NEBULA_SCALE);
|
||||
if (nebulaCanvas.width !== nw || nebulaCanvas.height !== nh) {
|
||||
nebulaCanvas.width = nw;
|
||||
nebulaCanvas.height = nh;
|
||||
}
|
||||
|
||||
// 每 3 帧更新一次星云(性能优化)
|
||||
const frame = Math.floor(t * 10);
|
||||
if (frame === lastNebulaTime) return;
|
||||
lastNebulaTime = frame;
|
||||
|
||||
const imgData = nebulaCtx.createImageData(nw, nh);
|
||||
const data = imgData.data;
|
||||
const st = t * 0.02; // 极慢流动
|
||||
|
||||
for (let y = 0; y < nh; y++) {
|
||||
for (let x = 0; x < nw; x++) {
|
||||
const nx = x / nw * 4;
|
||||
const ny = y / nh * 4;
|
||||
|
||||
// 多octave噪声叠加
|
||||
let n = noise(nx + st, ny + st * 0.7) * 0.5
|
||||
+ noise(nx * 2 + st * 0.5, ny * 2 - st * 0.3) * 0.3
|
||||
+ noise(nx * 4 - st * 0.2, ny * 4 + st * 0.4) * 0.2;
|
||||
n = (n + 1) * 0.5; // 归一化 0~1
|
||||
|
||||
const idx = (y * nw + x) * 4;
|
||||
|
||||
// 蓝紫星云
|
||||
const b1 = Math.max(0, n - 0.4) * 2.5;
|
||||
// 暗红星云(不同频率)
|
||||
let n2 = noise(nx * 3 + 100 + st * 0.3, ny * 3 + 50 - st * 0.2);
|
||||
n2 = (n2 + 1) * 0.5;
|
||||
const r1 = Math.max(0, n2 - 0.5) * 2;
|
||||
|
||||
data[idx] = Math.floor(b1 * 30 + r1 * 45); // R
|
||||
data[idx + 1] = Math.floor(b1 * 18 + r1 * 8); // G
|
||||
data[idx + 2] = Math.floor(b1 * 80 + r1 * 20); // B
|
||||
data[idx + 3] = Math.floor((b1 * 0.6 + r1 * 0.3) * 35); // A (非常淡)
|
||||
}
|
||||
}
|
||||
|
||||
nebulaCtx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
// ── 穿梭星线 ──
|
||||
const WARP_COUNT = 300;
|
||||
const WARP_SPEED = 0.006;
|
||||
const warpStars = Array.from({ length: WARP_COUNT }, () => ({
|
||||
x: (Math.random() - 0.5) * 2,
|
||||
y: (Math.random() - 0.5) * 2,
|
||||
z: Math.random(),
|
||||
pz: 0,
|
||||
}));
|
||||
|
||||
let t = 0;
|
||||
let meteorTimer = 0;
|
||||
const DRIFT_SPEED = 0.00003; // 极慢整体漂移
|
||||
|
||||
function draw() {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
t += 0.005;
|
||||
meteorTimer += 1;
|
||||
|
||||
// 清屏(半透明 → 穿梭拖尾)
|
||||
ctx.fillStyle = 'rgba(6, 6, 14, 0.18)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 整体漂移偏移
|
||||
const driftX = Math.sin(t * 0.3) * w * DRIFT_SPEED * 100;
|
||||
const driftY = Math.cos(t * 0.2) * h * DRIFT_SPEED * 100;
|
||||
|
||||
// ── 1. 星云 ──
|
||||
drawNebula(t);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(nebulaCanvas, 0, 0, w, h);
|
||||
|
||||
// ── 2. 星尘带 ──
|
||||
for (const d of starDust) {
|
||||
const sx = ((d.x + driftX * 0.3 / w) % 1) * w;
|
||||
const sy = d.y * h;
|
||||
ctx.fillStyle = `rgba(180, 190, 220, ${d.alpha})`;
|
||||
ctx.fillRect(sx, sy, d.r, d.r);
|
||||
}
|
||||
|
||||
// ── 3. 远景星 ──
|
||||
for (const s of farStars) {
|
||||
const alpha = s.alpha * (0.6 + 0.4 * Math.sin(t * s.speed + s.phase));
|
||||
const sx = ((s.x + driftX * 0.2 / w) % 1) * w;
|
||||
const sy = ((s.y + driftY * 0.2 / h) % 1) * h;
|
||||
ctx.fillStyle = `rgba(200, 210, 240, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── 4. 中景星 ──
|
||||
for (const s of midStars) {
|
||||
const alpha = s.alpha * (0.4 + 0.6 * Math.abs(Math.sin(t * s.speed + s.phase)));
|
||||
const sx = ((s.x + driftX * 0.5 / w) % 1) * w;
|
||||
const sy = ((s.y + driftY * 0.5 / h) % 1) * h;
|
||||
|
||||
// 柔和光晕
|
||||
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, s.r * 3);
|
||||
grad.addColorStop(0, `hsla(${s.hue}, 60%, 88%, ${alpha})`);
|
||||
grad.addColorStop(0.4, `hsla(${s.hue}, 50%, 70%, ${alpha * 0.3})`);
|
||||
grad.addColorStop(1, `hsla(${s.hue}, 40%, 50%, 0)`);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, s.r * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, s.r * 0.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${s.hue}, 50%, 95%, ${alpha * 0.8})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── 5. 前景星(大星 + 衍射芒) ──
|
||||
for (const s of nearStars) {
|
||||
const flicker = 0.3 + 0.7 * Math.abs(Math.sin(t * s.speed + s.phase));
|
||||
const alpha = s.alpha * flicker;
|
||||
const sx = ((s.x + driftX * 0.8 / w) % 1) * w;
|
||||
const sy = ((s.y + driftY * 0.8 / h) % 1) * h;
|
||||
const glow = s.r * (1 + 0.2 * Math.sin(t * s.speed * 1.5 + s.phase));
|
||||
|
||||
// 外层大光晕
|
||||
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, glow * 6);
|
||||
grad.addColorStop(0, `hsla(${s.hue}, 70%, 90%, ${alpha * 0.9})`);
|
||||
grad.addColorStop(0.15, `hsla(${s.hue}, 60%, 80%, ${alpha * 0.4})`);
|
||||
grad.addColorStop(0.5, `hsla(${s.hue}, 50%, 60%, ${alpha * 0.08})`);
|
||||
grad.addColorStop(1, `hsla(${s.hue}, 40%, 50%, 0)`);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, glow * 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// 核心
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, glow * 0.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${s.hue}, 40%, 97%, ${alpha})`;
|
||||
ctx.fill();
|
||||
|
||||
// 十字衍射芒
|
||||
const spikeLen = glow * 4 * flicker;
|
||||
const spikeAlpha = alpha * 0.25;
|
||||
ctx.strokeStyle = `hsla(${s.hue}, 50%, 90%, ${spikeAlpha})`;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath(); ctx.moveTo(sx - spikeLen, sy); ctx.lineTo(sx + spikeLen, sy); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(sx, sy - spikeLen); ctx.lineTo(sx, sy + spikeLen); ctx.stroke();
|
||||
// 45° 短芒
|
||||
const dLen = spikeLen * 0.4;
|
||||
ctx.lineWidth = 0.4;
|
||||
ctx.beginPath(); ctx.moveTo(sx - dLen, sy - dLen); ctx.lineTo(sx + dLen, sy + dLen); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(sx + dLen, sy - dLen); ctx.lineTo(sx - dLen, sy + dLen); ctx.stroke();
|
||||
}
|
||||
|
||||
// ── 6. 穿梭星线 ──
|
||||
for (const star of warpStars) {
|
||||
star.pz = star.z;
|
||||
star.z -= WARP_SPEED;
|
||||
if (star.z <= 0.001) {
|
||||
star.x = (Math.random() - 0.5) * 2;
|
||||
star.y = (Math.random() - 0.5) * 2;
|
||||
star.z = 1; star.pz = 1;
|
||||
}
|
||||
const sx2 = (star.x / star.z) * cx + cx;
|
||||
const sy2 = (star.y / star.z) * cy + cy;
|
||||
const px = (star.x / star.pz) * cx + cx;
|
||||
const py = (star.y / star.pz) * cy + cy;
|
||||
const size = (1 - star.z) * 1.8;
|
||||
const a = (1 - star.z) * 0.5;
|
||||
const hue2 = 220 + (1 - star.z) * 60;
|
||||
ctx.strokeStyle = `hsla(${hue2}, 70%, ${65 + (1 - star.z) * 25}%, ${a})`;
|
||||
ctx.lineWidth = size;
|
||||
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(sx2, sy2); ctx.stroke();
|
||||
}
|
||||
|
||||
// ── 7. 流星 ──
|
||||
if (meteorTimer > 600 + Math.random() * 300) { // ~10-15秒
|
||||
spawnMeteor();
|
||||
meteorTimer = 0;
|
||||
}
|
||||
for (let i = meteors.length - 1; i >= 0; i--) {
|
||||
const m = meteors[i];
|
||||
m.x += m.vx;
|
||||
m.y += m.vy;
|
||||
m.life -= 0.012;
|
||||
if (m.life <= 0) { meteors.splice(i, 1); continue; }
|
||||
|
||||
const mx = m.x * w;
|
||||
const my = m.y * h;
|
||||
const tailX = mx - m.vx * m.len * w;
|
||||
const tailY = my - m.vy * m.len * h;
|
||||
const grad = ctx.createLinearGradient(tailX, tailY, mx, my);
|
||||
grad.addColorStop(0, `hsla(${m.hue}, 60%, 80%, 0)`);
|
||||
grad.addColorStop(0.7, `hsla(${m.hue}, 70%, 90%, ${m.life * 0.4})`);
|
||||
grad.addColorStop(1, `hsla(0, 0%, 100%, ${m.life * 0.8})`);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.5 * m.life;
|
||||
ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(mx, my); ctx.stroke();
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className={styles.starfield} />;
|
||||
}
|
||||
|
||||
// ─── 浮动粒子层 ────────────────────────────────────────────
|
||||
function FloatingParticles() {
|
||||
const particles = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 8,
|
||||
duration: 6 + Math.random() * 10,
|
||||
size: 2 + Math.random() * 3,
|
||||
opacity: 0.15 + Math.random() * 0.3,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.particlesLayer}>
|
||||
{particles.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={styles.particle}
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
opacity: p.opacity,
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 登录页 ────────────────────────────────────────────────
|
||||
export default function LoginPage({ initialMessage = '', onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(initialMessage);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setError(initialMessage);
|
||||
}, [initialMessage]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
setError('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
onLogin(await login(username.trim(), password));
|
||||
} catch (err) {
|
||||
setError(err.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<DeepSpaceCanvas />
|
||||
<FloatingParticles />
|
||||
<div className={styles.radialGlow} />
|
||||
|
||||
<form className={styles.card} onSubmit={handleSubmit}>
|
||||
<div className={styles.cardGlow} />
|
||||
<img src="/logo-1.svg" alt="风电功率计算平台" className={styles.logo} />
|
||||
<h1 className={styles.title}>风电功率计算平台</h1>
|
||||
<p className={styles.subtitle}>数据分析 · 功率计算</p>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>用户名</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>密码</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<input
|
||||
id="login-password"
|
||||
className={styles.input}
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.eyeBtn}
|
||||
onClick={() => setShowPwd(!showPwd)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPwd ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error} role="alert">{error}</div>}
|
||||
|
||||
<button id="login-submit" className={styles.button} type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.footerDot} />
|
||||
<span className={styles.footerText}>Wind Power Analysis</span>
|
||||
<span className={styles.footerDot} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/* ─── 页面容器 ─── */
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: #080812;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── 星空穿梭 + 闪烁星星 Canvas ─── */
|
||||
.starfield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ─── 中心径向辉光 ─── */
|
||||
.radialGlow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 50% 50%,
|
||||
rgba(99, 102, 241, 0.08) 0%,
|
||||
transparent 70%),
|
||||
radial-gradient(ellipse 40% 30% at 50% 45%,
|
||||
rgba(139, 92, 246, 0.06) 0%,
|
||||
transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── 浮动粒子 ─── */
|
||||
.particlesLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
border-radius: 50%;
|
||||
background: #818cf8;
|
||||
filter: blur(1px);
|
||||
animation: floatUp linear infinite;
|
||||
}
|
||||
|
||||
@keyframes floatUp {
|
||||
0% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: var(--opacity, 0.3);
|
||||
}
|
||||
90% {
|
||||
opacity: var(--opacity, 0.3);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100vh) translateX(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 登录卡片 ─── */
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 400px;
|
||||
background: rgba(18, 18, 30, 0.75);
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 44px 36px 32px;
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
box-shadow:
|
||||
0 0 60px rgba(99, 102, 241, 0.08),
|
||||
0 0 120px rgba(139, 92, 246, 0.04),
|
||||
0 24px 48px rgba(0, 0, 0, 0.5);
|
||||
animation: cardEnter 0.8s ease-out;
|
||||
}
|
||||
|
||||
/* 卡片顶部光带 */
|
||||
.cardGlow {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(99, 102, 241, 0.6),
|
||||
rgba(168, 85, 247, 0.6),
|
||||
transparent);
|
||||
border-radius: 2px;
|
||||
animation: glowPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.5; left: 20%; right: 20%; }
|
||||
50% { opacity: 1; left: 10%; right: 10%; }
|
||||
}
|
||||
|
||||
/* ─── Logo ─── */
|
||||
.logo {
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 18px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 16px rgba(99, 102, 241, 0.3));
|
||||
animation: logoPulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 16px rgba(99, 102, 241, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 24px rgba(139, 92, 246, 0.5)); }
|
||||
}
|
||||
|
||||
/* ─── 标题 ─── */
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0 0 6px;
|
||||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe, #a5b4fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
margin: 16px 0 22px;
|
||||
letter-spacing: 6px;
|
||||
}
|
||||
|
||||
/* ─── 表单字段 ─── */
|
||||
.field {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: #8b8fa3;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: rgba(20, 20, 36, 0.8);
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
border-radius: 8px;
|
||||
color: #d1d5db;
|
||||
font-size: 17px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08);
|
||||
background: rgba(20, 20, 36, 1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #4a4e5a;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputWrap .input {
|
||||
padding-right: 42px;
|
||||
}
|
||||
|
||||
.eyeBtn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #4a4e5a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.eyeBtn:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* ─── 错误提示 ─── */
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: #f87171;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ─── 登录按钮 ─── */
|
||||
.button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
letter-spacing: 4px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.08),
|
||||
transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
box-shadow: 0 4px 24px rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:hover::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ─── 底部 ─── */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.footerDot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.footerText {
|
||||
font-size: 13px;
|
||||
color: #4a4e5a;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ async function request(url, options = {}) {
|
||||
|
||||
const resp = await fetch(`${BASE_URL}${url}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
@@ -33,6 +34,11 @@ async function request(url, options = {}) {
|
||||
|
||||
// 业务失败
|
||||
if (json.status !== 0) {
|
||||
if (resp.status === 401 && url !== '/auth/login' && url !== '/auth/me') {
|
||||
window.dispatchEvent(new CustomEvent('wind-auth-required', {
|
||||
detail: { message: json.msg || '请先登录' },
|
||||
}));
|
||||
}
|
||||
throw new Error(json.msg || '请求失败');
|
||||
}
|
||||
return json.data;
|
||||
@@ -54,6 +60,39 @@ export function getHealth() {
|
||||
return request('/system/health');
|
||||
}
|
||||
|
||||
export function login(username, password) {
|
||||
return request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return request('/auth/me');
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return request('/admin/accounts');
|
||||
}
|
||||
|
||||
export function createAccount(payload) {
|
||||
return request('/admin/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(username, payload) {
|
||||
return request(`/admin/accounts/${encodeURIComponent(username)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计算方案列表
|
||||
* @returns {Promise<{default_scheme_id: string, schemes: Array<object>}>}
|
||||
@@ -108,8 +147,8 @@ export function startWindJob(payload) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传标准化后的数据分片
|
||||
* @param {{job_id: string, chunk_index: number, rows: Array<object>}} payload
|
||||
* 上传完整原始数据分片,服务端按任务列映射生成计算行。
|
||||
* @param {{job_id: string, chunk_index: number, raw_rows: Array<object>}} payload
|
||||
* @returns {Promise<{accepted_rows: number}>}
|
||||
*/
|
||||
export function uploadWindChunk(payload) {
|
||||
@@ -131,6 +170,11 @@ export function finishWindJob(payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取单台风机的完整有效点与过滤点。 */
|
||||
export function getWindFanPoints(jobId, fanId) {
|
||||
return request(`/wind/jobs/${encodeURIComponent(jobId)}/fans/${encodeURIComponent(fanId)}/points`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未完成的计算任务
|
||||
* @param {string} jobId
|
||||
@@ -147,6 +191,7 @@ export async function downloadWindReport(jobId, payload) {
|
||||
const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -154,11 +199,19 @@ export async function downloadWindReport(jobId, payload) {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (resp.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('wind-auth-required', {
|
||||
detail: { message: json.msg || '请先登录' },
|
||||
}));
|
||||
}
|
||||
throw new Error(json.msg || '完整报告生成失败');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return resp.blob();
|
||||
return {
|
||||
blob: await resp.blob(),
|
||||
serverTiming: resp.headers.get('Server-Timing') || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
function clamp(value, minimum, maximum) {
|
||||
if (maximum < minimum) return minimum;
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
export function resolvePowerCurveLegendLayout({
|
||||
plotLeft,
|
||||
plotTop,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
fontSize,
|
||||
entries,
|
||||
textWidths,
|
||||
}) {
|
||||
const inset = 18;
|
||||
const markerWidth = 20;
|
||||
const labelOffset = 30;
|
||||
const textHeight = Math.max(1, Math.ceil(fontSize * 1.2));
|
||||
const lineHeight = Math.max(34, textHeight + 10);
|
||||
const legendWidth = markerWidth + labelOffset + Math.max(0, ...textWidths);
|
||||
const legendHeight = textHeight + Math.max(0, entries.length - 1) * lineHeight;
|
||||
const plotRight = plotLeft + plotWidth;
|
||||
const plotBottom = plotTop + plotHeight;
|
||||
const minLeft = plotLeft + inset;
|
||||
const minTop = plotTop + inset;
|
||||
const desiredLeft = plotLeft + 22;
|
||||
const desiredTop = plotTop + Math.max(30, fontSize + 10);
|
||||
|
||||
return {
|
||||
left: clamp(desiredLeft, minLeft, plotRight - inset - legendWidth),
|
||||
top: clamp(desiredTop, minTop, plotBottom - inset - legendHeight),
|
||||
markerWidth,
|
||||
labelOffset,
|
||||
textHeight,
|
||||
lineHeight,
|
||||
width: legendWidth,
|
||||
height: legendHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolvePowerCurveLegendLayout } from './powerCurveLegendLayout.js';
|
||||
|
||||
function assertInsidePlot(layout, { left, top, width, height }) {
|
||||
assert.ok(layout.left >= left);
|
||||
assert.ok(layout.top >= top);
|
||||
assert.ok(layout.left + layout.width <= left + width);
|
||||
assert.ok(layout.top + layout.height <= top + height);
|
||||
}
|
||||
|
||||
test('keeps three-entry legends inside the plot at every supported text scale', () => {
|
||||
for (const fontSize of [18, 24, 36]) {
|
||||
const plot = { left: 160, top: 120, width: 1570, height: 905 };
|
||||
const layout = resolvePowerCurveLegendLayout({
|
||||
plotLeft: plot.left,
|
||||
plotTop: plot.top,
|
||||
plotWidth: plot.width,
|
||||
plotHeight: plot.height,
|
||||
fontSize,
|
||||
entries: [['散点数据', 'scatter'], ['实际功率', 'actual'], ['设计功率', 'design']],
|
||||
textWidths: [fontSize * 4, fontSize * 4, fontSize * 4],
|
||||
});
|
||||
assertInsidePlot(layout, plot);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps a four-entry legend inside the plot', () => {
|
||||
const plot = { left: 160, top: 142, width: 1570, height: 861 };
|
||||
const layout = resolvePowerCurveLegendLayout({
|
||||
plotLeft: plot.left,
|
||||
plotTop: plot.top,
|
||||
plotWidth: plot.width,
|
||||
plotHeight: plot.height,
|
||||
fontSize: 36,
|
||||
entries: [['散点数据', 'scatter'], ['实际功率', 'actual'], ['设计功率', 'design'], ['滤除点', 'filtered']],
|
||||
textWidths: [144, 144, 144, 108],
|
||||
});
|
||||
assertInsidePlot(layout, plot);
|
||||
});
|
||||
@@ -101,6 +101,17 @@ export function getDrawableActualCurve(actualCurve = []) {
|
||||
.sort((left, right) => left.wind_speed - right.wind_speed);
|
||||
}
|
||||
|
||||
export function hasDrawablePowerCurveData({
|
||||
actualCurve = [],
|
||||
designCurve = [],
|
||||
scatterPoints = [],
|
||||
filteredPoints = [],
|
||||
showFiltered = false,
|
||||
} = {}) {
|
||||
return Boolean(actualCurve.length || designCurve.length || scatterPoints.length ||
|
||||
(showFiltered && filteredPoints.length));
|
||||
}
|
||||
|
||||
function interpolatePowerAtWindSpeed(designCurve, windSpeed) {
|
||||
if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON
|
||||
|| windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildActualCurveFromDesign, getDrawableActualCurve } from './powerCurveModel.js';
|
||||
import {
|
||||
buildActualCurveFromDesign,
|
||||
getDrawableActualCurve,
|
||||
hasDrawablePowerCurveData,
|
||||
} from './powerCurveModel.js';
|
||||
|
||||
test('getDrawableActualCurve skips empty design bins and keeps adjacent valid points', () => {
|
||||
const actualCurve = buildActualCurveFromDesign(
|
||||
@@ -27,3 +31,12 @@ test('getDrawableActualCurve skips empty design bins and keeps adjacent valid po
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('allows a chart to render when only scatter points are available', () => {
|
||||
assert.equal(hasDrawablePowerCurveData({
|
||||
actualCurve: [],
|
||||
designCurve: [],
|
||||
scatterPoints: [{ wind_speed: 8, active_power: 1500 }],
|
||||
}), true);
|
||||
assert.equal(hasDrawablePowerCurveData({}), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export const BASE_REQUIRED_FIELDS = [
|
||||
{ key: 'time', label: '时间' },
|
||||
{ key: 'fan_id', label: '风机编号' },
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
{ key: 'active_power', label: '有功功率' },
|
||||
{ key: 'generator_speed', label: '发电机转速' },
|
||||
];
|
||||
|
||||
export const ROTOR_SPEED_FIELD = { key: 'rotor_speed', label: '叶轮转速' };
|
||||
|
||||
export const PITCH_REQUIRED_FIELDS = [
|
||||
{ key: 'blade_pitch_1', label: '1#叶片角度' },
|
||||
{ key: 'blade_pitch_2', label: '2#叶片角度' },
|
||||
{ key: 'blade_pitch_3', label: '3#叶片角度' },
|
||||
];
|
||||
|
||||
export const REQUIRED_FIELDS = [
|
||||
...BASE_REQUIRED_FIELDS,
|
||||
ROTOR_SPEED_FIELD,
|
||||
...PITCH_REQUIRED_FIELDS,
|
||||
];
|
||||
|
||||
const FIELD_HINTS = {
|
||||
time: ['时间', 'time', 'timestamp', '日期'],
|
||||
fan_id: ['风机编号', '机组编号', '机组名称', '风机名称', '风机', 'fan', 'turbine'],
|
||||
wind_speed: ['风速', '平均风速', 'wind speed', 'windspeed'],
|
||||
active_power: ['平均有功功率', '有功功率', 'active power', 'power'],
|
||||
generator_speed: ['发电机转速', '平均发电机转速', 'generator speed', 'rpm'],
|
||||
rotor_speed: ['风轮实时转速', '叶轮实时转速', '风轮转速', '叶轮转速', '主轴转速', '转子转速', 'rotor speed', 'rotor rpm'],
|
||||
blade_pitch_1: ['1#叶片变桨角度', '1#叶片角度', '1叶片变桨角度', '1叶片角度', '1号叶片角度', '叶片1角度', '桨角1', '变桨角1', 'pitch 1', 'pitch angle 1', 'blade pitch 1'],
|
||||
blade_pitch_2: ['2#叶片变桨角度', '2#叶片角度', '2叶片变桨角度', '2叶片角度', '2号叶片角度', '叶片2角度', '桨角2', '变桨角2', 'pitch 2', 'pitch angle 2', 'blade pitch 2'],
|
||||
blade_pitch_3: ['3#叶片变桨角度', '3#叶片角度', '3叶片变桨角度', '3叶片角度', '3号叶片角度', '叶片3角度', '桨角3', '变桨角3', 'pitch 3', 'pitch angle 3', 'blade pitch 3'],
|
||||
};
|
||||
|
||||
const FIELD_EXCLUDES = {
|
||||
active_power: ['限功率', '限电', '时间', '累计'],
|
||||
generator_speed: ['风轮', '叶轮', '主轴', '转子'],
|
||||
rotor_speed: ['发电机'],
|
||||
};
|
||||
|
||||
export function normalizeHeader(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[()()_\-./]/g, '');
|
||||
}
|
||||
|
||||
export function inferWindMapping(headers) {
|
||||
const normalized = headers.map((header) => ({
|
||||
header,
|
||||
normalized: normalizeHeader(header),
|
||||
}));
|
||||
const mapping = {};
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
const hints = FIELD_HINTS[field.key].map(normalizeHeader);
|
||||
const excludes = (FIELD_EXCLUDES[field.key] || []).map(normalizeHeader);
|
||||
let best = null;
|
||||
for (const item of normalized) {
|
||||
if (!item.normalized || excludes.some((exclude) => item.normalized.includes(exclude))) {
|
||||
continue;
|
||||
}
|
||||
let score = 0;
|
||||
hints.forEach((hint, index) => {
|
||||
const weight = hints.length - index;
|
||||
if (item.normalized === hint) {
|
||||
score = Math.max(score, 100 + weight);
|
||||
} else if (item.normalized.includes(hint)) {
|
||||
score = Math.max(score, 60 + weight);
|
||||
} else if (hint.includes(item.normalized)) {
|
||||
score = Math.max(score, 20 + weight);
|
||||
}
|
||||
});
|
||||
if (!best || score > best.score) {
|
||||
best = { ...item, score };
|
||||
}
|
||||
}
|
||||
mapping[field.key] = best && best.score > 0 ? best.header : '';
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { inferWindMapping } from './windFieldMapping.js';
|
||||
|
||||
test('maps generator and rotor speed to distinct Chinese columns', () => {
|
||||
const mapping = inferWindMapping([
|
||||
'时间',
|
||||
'机组名称',
|
||||
'平均风速(m/s)',
|
||||
'平均有功功率(kW)',
|
||||
'发电机转速(rpm)',
|
||||
'风轮实时转速(rpm)',
|
||||
]);
|
||||
|
||||
assert.equal(mapping.generator_speed, '发电机转速(rpm)');
|
||||
assert.equal(mapping.rotor_speed, '风轮实时转速(rpm)');
|
||||
assert.equal(mapping.fan_id, '机组名称');
|
||||
});
|
||||
|
||||
test('does not use a generator speed column as rotor speed', () => {
|
||||
const mapping = inferWindMapping(['发电机转速(rpm)']);
|
||||
|
||||
assert.equal(mapping.generator_speed, '发电机转速(rpm)');
|
||||
assert.equal(mapping.rotor_speed, '');
|
||||
});
|
||||
Reference in New Issue
Block a user