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

- 新增账号管理、有效期、启停控制与登录工作台布局
- 新增方案三叶轮转速计算及字段映射和报告支持
- 修复功率曲线断线、图例越界及方案一散点绘制
- 优化计算进度、浅色主题和当前方案提示
- 补充前端测试与部署依赖配置
This commit is contained in:
cloud
2026-09-16 15:14:45 +08:00
parent befbf6c029
commit 58be5a34f6
26 changed files with 3357 additions and 297 deletions
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- 六边形 + 内部连接节点:工业网络枢纽 -->
<path d="M16 2 L28 9 L28 23 L16 30 L4 23 L4 9 Z" stroke="#6366f1" stroke-width="2" fill="rgba(99,102,241,0.1)"/>
<circle cx="16" cy="10" r="2.5" fill="#6366f1"/>
<circle cx="10" cy="21" r="2.5" fill="#6366f1"/>
<circle cx="22" cy="21" r="2.5" fill="#6366f1"/>
<line x1="16" y1="10" x2="10" y2="21" stroke="#6366f1" stroke-width="1.5"/>
<line x1="16" y1="10" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
<line x1="10" y1="21" x2="22" y2="21" stroke="#6366f1" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 645 B

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