功能: 服务端生成完整报告并保护单任务

- 使用 libxlsxwriter 常量内存导出 Excel\n- 增加全局任务占用提示与原始数据持久化\n- 保留运行时任务数据并更新接口文档
This commit is contained in:
cloud
2026-08-05 16:47:31 +08:00
parent 07274797af
commit 99ed321d24
2292 changed files with 134317 additions and 126 deletions
+45
View File
@@ -254,6 +254,13 @@
margin-top: 14px;
}
.schemeParamHint {
margin: 14px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.schemeParamGrid input {
min-width: 0;
width: 100%;
@@ -805,6 +812,44 @@ td {
.error { border-color: #fecaca; background: #fff1f2; color: #b91c1c; }
.alert.info { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
.dialogBackdrop {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.42);
}
.appDialog {
width: min(440px, 100%);
padding: 22px;
border: 1px solid #dbe3ed;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
}
.appDialog h2 {
margin: 0;
color: #172033;
font-size: 18px;
}
.appDialog p {
margin: 12px 0 0;
color: #475569;
font-size: 14px;
line-height: 1.6;
}
.appDialogActions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.schemeDescriptionControl span,
.fieldControl span { color: #334155; }
.schemeDescriptionControl input,
+294 -99
View File
@@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css';
import * as XLSX from 'xlsx';
import {
deleteWindJob,
downloadWindReport,
finishWindJob,
getWindChartOptions,
getWindSchemes,
@@ -58,7 +59,24 @@ const EDIT_TOOL_ERASE = 'erase';
const DEFAULT_SCHEME_ID = 'scheme_one';
const SCHEME_TWO_ID = 'scheme_two';
const DEFAULT_SCHEMES = [
{ id: 'scheme_one', name: '方案一', description: '通用方案' },
{
id: 'scheme_one',
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,
gearbox_ratio: 162,
},
},
{
id: SCHEME_TWO_ID,
name: '方案二',
@@ -76,6 +94,32 @@ const DEFAULT_SCHEME_TWO_PARAMS = {
rated_generator_speed: '1755',
rated_power: '2000',
};
const DEFAULT_SCHEME_ONE_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',
gearbox_ratio: '162',
};
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' },
{ key: 'power_step', label: '功率分箱步长', unit: 'kW', min: '0', step: '0.01' },
{ key: 'cleaning_wind_speed_step', label: '清洗风速分箱步长', unit: 'm/s', min: '0', step: '0.01' },
{ key: 'wind_speed_change_threshold', label: '风速变化阈值', unit: 'm/s', min: '0', step: '0.01' },
{ key: 'iqr_lower_multiplier', label: 'IQR 下限系数', unit: '', min: '0', step: '0.01' },
{ key: 'iqr_upper_multiplier', label: 'IQR 上限系数', unit: '', min: '0', step: '0.01' },
{ key: 'minimum_generator_speed', label: '最小发电机转速', unit: 'rpm', min: '0', step: '0.01' },
{ key: 'generator_speed_k', label: '发电机转速系数 K', unit: '', min: '0', step: '0.01' },
{ key: 'rotor_radius', label: '叶轮半径', unit: 'm', min: '0', step: '0.01' },
{ key: 'gearbox_ratio', label: '齿轮箱传动比', unit: '', min: '0', step: '0.01' },
];
const CHART_STATE_KEY = 'wind_power_chart_state_v1';
const DEFAULT_CHART_OPTIONS = {
title: '#机组功率曲线',
@@ -393,11 +437,13 @@ function buildStandardRows(files, mapping) {
);
const rows = [];
const rawRows = [];
for (const file of files) {
const localIndexes = Object.fromEntries(
REQUIRED_FIELDS.map((field) => [field.key, file.headers.indexOf(mapping[field.key])]),
);
for (const row of file.rows) {
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(),
@@ -408,10 +454,14 @@ function buildStandardRows(files, mapping) {
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)],
});
}
}
return { rows, indexes };
return { rows, rawRows, indexes };
}
function hasMatchingHeaderSequence(files) {
@@ -423,44 +473,6 @@ function hasMatchingHeaderSequence(files) {
));
}
function findRawSourceFiles(files, mapping, fanId) {
if (!fanId) return [];
return files.filter((file) => {
const fanColumnIndex = file.headers.indexOf(mapping.fan_id);
return fanColumnIndex >= 0 && file.rows.some((row) => (
String(row[fanColumnIndex] ?? '').trim() === fanId
));
});
}
function buildRawReportData(file, mapping, fanId) {
if (!file || !fanId) return null;
const fanColumnIndex = file.headers.indexOf(mapping.fan_id);
const windSpeedColumnIndex = file.headers.indexOf(mapping.wind_speed);
if (fanColumnIndex < 0 || windSpeedColumnIndex < 0) return null;
const rows = file.rows.reduce((result, row, index) => {
if (String(row[fanColumnIndex] ?? '').trim() === fanId) {
const rawRow = [...(file.raw_rows?.[index] || row)];
const windSpeed = normalizeNumber(rawRow[windSpeedColumnIndex]);
// 部分源 Excel 将风速保存为“数字文本”。保留显示值的同时写成数值单元格,
// 使计算表的 COUNTIFS 能正确统计风频。
if (Number.isFinite(windSpeed)) {
rawRow[windSpeedColumnIndex] = windSpeed;
}
result.push(rawRow);
}
return result;
}, []);
return {
headers: [...(file.raw_headers || file.headers)],
rows,
windSpeedColumnIndex,
};
}
function formatTimestampForFile(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
@@ -585,6 +597,13 @@ function schemeParamsToState(parameters) {
};
}
function schemeOneParamsToState(parameters) {
return Object.fromEntries(Object.entries(DEFAULT_SCHEME_ONE_PARAMS).map(([field, fallback]) => [
field,
String(parameters?.[field] ?? fallback),
]));
}
function SummaryCards({ summary }) {
if (!summary) return null;
const cards = [
@@ -972,14 +991,25 @@ async function captureChartFrame(frame) {
};
}
function downloadReportBlob(blob, fanId) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `完整功率曲线报告_${sanitizeFileName(fanId)}_${formatTimestampForFile()}.xlsx`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
/* 已迁移到 reportExportWorker,由 Worker 承担 ExcelJS 的内存压力。 */
/*
const REPORT_DETAIL_HEADERS = [
'风机编号',
'采样时间',
'平均功率',
'平均转速',
'平均风速',
'3个叶片变桨角平均值',
];
const REPORT_PITCH_HEADER = '3个叶片变桨角平均值';
const REPORT_BORDER = {
top: { style: 'thin', color: { argb: 'FF475569' } },
@@ -1000,16 +1030,19 @@ function reportPitchAverage(row) {
return values.every((value) => value !== '') ? values.reduce((sum, value) => sum + value, 0) / values.length : '';
}
function reportDetailValues(row, fanId) {
function reportDetailValues(row, fanId, includePitch) {
const savedPitchAverage = reportNumber(row.pitch_angle_average);
return [
const values = [
row.fan_id || fanId || '',
row.time || '',
reportNumber(row.active_power),
reportNumber(row.generator_speed),
reportNumber(row.wind_speed),
savedPitchAverage !== '' ? savedPitchAverage : reportPitchAverage(row),
];
if (includePitch) {
values.push(savedPitchAverage !== '' ? savedPitchAverage : reportPitchAverage(row));
}
return values;
}
function styleReportHeader(row) {
@@ -1036,17 +1069,25 @@ function styleReportBody(sheet, startRow, endRow, numericColumns) {
}
function addReportDetailSheet(workbook, name, rows, fanId) {
const includePitch = rows.some((row) => (
reportNumber(row.pitch_angle_average) !== '' || reportPitchAverage(row) !== ''
));
const headers = includePitch ? [...REPORT_DETAIL_HEADERS, REPORT_PITCH_HEADER] : REPORT_DETAIL_HEADERS;
const sheet = workbook.addWorksheet(name, {
views: [{ state: 'frozen', ySplit: 1 }],
});
sheet.columns = [
{ width: 16 }, { width: 22 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 23 },
{ width: 16 }, { width: 22 }, { width: 14 }, { width: 14 }, { width: 14 },
...(includePitch ? [{ width: 23 }] : []),
];
sheet.addRow(REPORT_DETAIL_HEADERS);
rows.forEach((row) => sheet.addRow(reportDetailValues(row, fanId)));
sheet.addRow(headers);
rows.forEach((row) => sheet.addRow(reportDetailValues(row, fanId, includePitch)));
styleReportHeader(sheet.getRow(1));
styleReportBody(sheet, 2, Math.max(2, rows.length + 1), [3, 4, 5, 6]);
sheet.autoFilter = `A1:F${Math.max(1, rows.length + 1)}`;
// 对每个数据单元格设置样式会使 ExcelJS 为大量行保留大量对象,容易耗尽浏览器内存。
for (const columnNumber of includePitch ? [3, 4, 5, 6] : [3, 4, 5]) {
sheet.getColumn(columnNumber).numFmt = '0.0000';
}
sheet.autoFilter = `A1:${includePitch ? 'F' : 'E'}${Math.max(1, rows.length + 1)}`;
return sheet;
}
@@ -1067,14 +1108,15 @@ function addRawReportSheet(workbook, { headers, rows }) {
});
sheet.addRow(headers);
rows.forEach((row) => sheet.addRow(row));
// 列宽只需参考样本即可,避免大文件导出时重复扫描全部单元格。
const widthSampleRows = rows.slice(0, 1000);
sheet.columns = headers.map((header, index) => {
const maxLength = rows.reduce((max, row) => (
const maxLength = widthSampleRows.reduce((max, row) => (
Math.max(max, String(row[index] ?? '').length)
), String(header ?? '').length);
return { width: Math.min(32, Math.max(12, maxLength + 2)) };
});
styleReportHeader(sheet.getRow(1));
styleReportBody(sheet, 2, Math.max(2, rows.length + 1), []);
sheet.autoFilter = `A1:${excelColumnName(Math.max(0, headers.length - 1))}${Math.max(1, rows.length + 1)}`;
return sheet;
}
@@ -1086,7 +1128,6 @@ async function exportPowerCurveReport({
reportRows,
chartRenderSnapshot,
}) {
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
workbook.creator = '风电功率计算平台';
workbook.created = new Date();
@@ -1157,6 +1198,8 @@ async function exportPowerCurveReport({
URL.revokeObjectURL(url);
}
*/
function PowerCurveChart({
points,
scatterPoints,
@@ -1218,7 +1261,7 @@ function PowerCurveChart({
const yAxisSize = Math.max(64, Math.round(screenTypography.tick * 3.2 + 16));
const axisLabelSize = screenTypography.axis + 18;
const showScatterOnChart = activeChartOptions.show_scatter || editMode;
chartSnapshotRef.current = async () => {
chartSnapshotRef.current = async (captureFrame = true) => {
const snapshot = {
actualCurve: visibleActualCurve,
designCurve: visibleDesignCurve,
@@ -1231,6 +1274,9 @@ function PowerCurveChart({
options: activeChartOptions,
scales: plotRef.current?.scales,
};
if (!captureFrame) {
return snapshot;
}
try {
const capturedImage = await captureChartFrame(frameRef.current);
return capturedImage ? { ...snapshot, ...capturedImage } : snapshot;
@@ -1244,6 +1290,8 @@ function PowerCurveChart({
useEffect(() => {
if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined;
const transientErasedPointIds = new Set();
let redrawFrameId = 0;
const sortedCurve = [...visibleActualCurve].sort((left, right) => left.wind_speed - right.wind_speed);
const sortedDesign = [...visibleDesignCurve].sort((left, right) => left.wind_speed - right.wind_speed);
const actualByWindSpeed = new Map(
@@ -1372,6 +1420,9 @@ function PowerCurveChart({
if (showScatterOnChart) {
ctx.fillStyle = hexToRgba(activeChartOptions.scatter_color, activeChartOptions.scatter_opacity);
for (const point of validScatter) {
if (transientErasedPointIds.has(point.point_id)) {
continue;
}
const x = u.valToPos(point.wind_speed, 'x', true);
const y = u.valToPos(point.active_power, 'y', true);
if (Number.isFinite(x) && Number.isFinite(y)) {
@@ -1437,6 +1488,16 @@ function PowerCurveChart({
plotRef.current = chart;
setSeriesVisibility({ actual: true, design: true });
const requestChartRedraw = () => {
if (redrawFrameId) {
return;
}
redrawFrameId = window.requestAnimationFrame(() => {
redrawFrameId = 0;
chart.redraw(true, false);
});
};
const hideBrush = () => {
setBrush((prev) => ({ ...prev, visible: false, dragging: false }));
};
@@ -1449,6 +1510,7 @@ function PowerCurveChart({
}
let dragging = false;
const pendingPoints = new Map();
const updateBrush = (event, nextDragging = dragging) => {
if (!frameRef.current) return;
const rect = frameRef.current.getBoundingClientRect();
@@ -1484,16 +1546,32 @@ function PowerCurveChart({
if (!hitPoints.length) {
return;
}
for (const point of hitPoints) {
const pointId = point.point_id || `${point.wind_speed}_${point.active_power}`;
pendingPoints.set(pointId, point);
if (editTool === EDIT_TOOL_ERASE) {
transientErasedPointIds.add(pointId);
}
}
if (editTool === EDIT_TOOL_ERASE) {
onErasePoints?.(hitPoints);
requestChartRedraw();
}
};
const applyPendingPoints = () => {
if (!pendingPoints.size) return;
const points = Array.from(pendingPoints.values());
pendingPoints.clear();
if (editTool === EDIT_TOOL_ERASE) {
onErasePoints?.(points);
} else {
onRestorePoints?.(hitPoints);
onRestorePoints?.(points);
}
};
const handlePointerDown = (event) => {
if (event.button !== 0) return;
event.preventDefault();
dragging = true;
chart.over.setPointerCapture?.(event.pointerId);
updateBrush(event, true);
applyToolAt(event);
};
@@ -1506,6 +1584,11 @@ function PowerCurveChart({
const handlePointerUp = (event) => {
updateBrush(event, false);
dragging = false;
applyToolAt(event);
applyPendingPoints();
if (chart.over.hasPointerCapture?.(event.pointerId)) {
chart.over.releasePointerCapture(event.pointerId);
}
};
chart.over.addEventListener('pointerdown', handlePointerDown);
@@ -1518,6 +1601,10 @@ function PowerCurveChart({
chart.over.removeEventListener('pointermove', handlePointerMove);
chart.over.removeEventListener('pointerleave', hideBrush);
window.removeEventListener('pointerup', handlePointerUp);
if (redrawFrameId) {
window.cancelAnimationFrame(redrawFrameId);
}
pendingPoints.clear();
if (plotRef.current === chart) plotRef.current = null;
chart.destroy();
};
@@ -1626,6 +1713,8 @@ export default function HomePage() {
const [savingChartOptions, setSavingChartOptions] = useState(false);
const [chartSettingsMessage, setChartSettingsMessage] = useState('');
const [exportingReport, setExportingReport] = useState(false);
const [reportExportProgress, setReportExportProgress] = useState('');
const [reportExportNotice, setReportExportNotice] = useState('');
const chartExportRef = useRef(() => false);
const chartSnapshotRef = useRef(() => null);
const [schemes, setSchemes] = useState(DEFAULT_SCHEMES);
@@ -1633,12 +1722,14 @@ export default function HomePage() {
const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description);
const [savingScheme, setSavingScheme] = useState(false);
const [schemeMessage, setSchemeMessage] = useState('');
const [schemeOneParams, setSchemeOneParams] = useState(DEFAULT_SCHEME_ONE_PARAMS);
const [schemeTwoParams, setSchemeTwoParams] = useState(DEFAULT_SCHEME_TWO_PARAMS);
const headers = files[0]?.headers || [];
const missingFields = REQUIRED_FIELDS.filter((field) => !mapping[field.key]);
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 missingFields = mappingFields.filter((field) => !mapping[field.key]);
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
const selectedScatterRaw = selectedFan && result?.scatter_points
@@ -1702,14 +1793,6 @@ export default function HomePage() {
() => hasMatchingHeaderSequence(files),
[files],
);
const rawReportSourceFiles = useMemo(
() => findRawSourceFiles(files, mapping, selectedFan),
[files, mapping, selectedFan],
);
const selectedRawReportData = useMemo(() => {
if (!hasConsistentRawHeaders || rawReportSourceFiles.length !== 1) return null;
return buildRawReportData(rawReportSourceFiles[0], mapping, selectedFan);
}, [hasConsistentRawHeaders, mapping, rawReportSourceFiles, selectedFan]);
const selectedEstimatedParams = selectedFan && result?.estimated_params
? result.estimated_params[selectedFan]
: null;
@@ -1924,48 +2007,68 @@ export default function HomePage() {
() => buildReportCurveRows(effectiveScatter, reportDesignCurve),
[effectiveScatter, reportDesignCurve],
);
const reportExportDisabledReason = files.length && !hasConsistentRawHeaders
? '同一批上传文件的列头和列顺序必须完全一致'
: files.length && rawReportSourceFiles.length !== 1
? '完整报告要求当前风机对应唯一一份上传 Excel 文件'
: files.length && (!selectedRawReportData || !selectedRawReportData.rows.length)
? '当前风机没有可导出的原始数据,或未识别到风速列'
const reportExportDisabledReason = !selectedFan
? '请先完成计算并选择要导出的风机'
: isEditMode
? '请先退出曲线编辑模式,再导出完整报告'
: !reportDesignCurve.length
? '请先填写或上传设计功率曲线'
: !result?.job_id
? '当前计算结果已失效,请重新计算后导出报告'
: files.length && !hasConsistentRawHeaders
? '同一批上传文件的列头和列顺序必须完全一致'
: !reportCurveRows.length
? '当前设计功率曲线与有效数据未形成可导出的功率曲线点'
: '';
const canExportFullReport = Boolean(
selectedFan && selectedRawReportData?.rows.length && reportCurveRows.length && !isEditMode &&
!reportExportDisabledReason,
);
const handleExportFullReport = useCallback(async () => {
if (!canExportFullReport || exportingReport) return;
if (exportingReport) return;
if (!canExportFullReport) {
setReportExportNotice(reportExportDisabledReason || '当前条件不满足,无法导出完整报告');
return;
}
setExportingReport(true);
setReportExportProgress('正在服务器生成完整报告');
setError('');
try {
const chartRenderSnapshot = await chartSnapshotRef.current?.();
const chartRenderSnapshot = await chartSnapshotRef.current?.(false);
if (!chartRenderSnapshot) {
throw new Error('图表尚未完成绘制,请稍后重试');
}
await exportPowerCurveReport({
fanId: selectedFan,
rawReportData: selectedRawReportData,
effectiveRows: effectiveScatter,
reportRows: reportCurveRows,
chartRenderSnapshot,
const chartImageData = renderPowerCurvePng(chartRenderSnapshot);
const blob = await downloadWindReport(result.job_id, {
fan_id: selectedFan,
effective_rows: effectiveScatter,
report_rows: reportCurveRows,
chart_image: chartImageData || '',
});
downloadReportBlob(blob, selectedFan);
} catch (err) {
setError(err.message || '导出完整报告失败');
} finally {
const message = err.message || '导出完整报告失败';
setError(message);
setReportExportNotice(message);
setExportingReport(false);
setReportExportProgress('');
}
}, [
canExportFullReport,
effectiveScatter,
exportingReport,
reportExportDisabledReason,
reportCurveRows,
rawReportSourceFiles,
selectedRawReportData,
selectedFan,
result?.job_id,
]);
const handleCancelReportExport = useCallback(() => {
setExportingReport(false);
setReportExportProgress('');
setReportExportNotice('已取消完整报告导出');
}, []);
useEffect(() => {
let canceled = false;
@@ -1981,6 +2084,8 @@ export default function HomePage() {
setSchemes(loadedSchemes);
setSelectedSchemeId(defaultScheme.id);
setSchemeDescription(defaultScheme.description || '');
const schemeOne = loadedSchemes.find((scheme) => scheme.id === DEFAULT_SCHEME_ID);
setSchemeOneParams(schemeOneParamsToState(schemeOne?.parameters));
const schemeTwo = loadedSchemes.find((scheme) => scheme.id === SCHEME_TWO_ID);
setSchemeTwoParams(schemeParamsToState(schemeTwo?.parameters));
} catch (err) {
@@ -2000,7 +2105,9 @@ export default function HomePage() {
const nextScheme = schemes.find((scheme) => scheme.id === value);
setSelectedSchemeId(value);
setSchemeDescription(nextScheme?.description || '');
if (value === SCHEME_TWO_ID) {
if (value === DEFAULT_SCHEME_ID) {
setSchemeOneParams(schemeOneParamsToState(nextScheme?.parameters));
} else if (value === SCHEME_TWO_ID) {
setSchemeTwoParams(schemeParamsToState(nextScheme?.parameters));
}
setSchemeMessage('');
@@ -2013,6 +2120,13 @@ export default function HomePage() {
}));
}
function handleSchemeOneParamChange(field, value) {
setSchemeOneParams((prev) => ({
...prev,
[field]: value,
}));
}
async function handleSaveSchemeDescription() {
if (!selectedScheme) return;
@@ -2020,9 +2134,26 @@ export default function HomePage() {
setError('');
setSchemeMessage('');
try {
const schemeOneValues = Object.fromEntries(Object.entries(schemeOneParams).map(([field, value]) => [
field,
normalizeNumber(value),
]));
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
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 ||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
)))) {
throw new Error('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
}
if (selectedScheme.id === SCHEME_TWO_ID &&
(!Number.isFinite(gridConnectedSpeed) || gridConnectedSpeed <= 0 ||
!Number.isFinite(ratedGeneratorSpeed) || ratedGeneratorSpeed <= 0 ||
@@ -2031,6 +2162,7 @@ export default function HomePage() {
}
const data = await saveWindSchemeDescription(selectedScheme.id, {
description: schemeDescription,
...(selectedScheme.id === DEFAULT_SCHEME_ID ? { parameters: schemeOneValues } : {}),
...(selectedScheme.id === SCHEME_TWO_ID ? {
parameters: {
grid_connected_speed: gridConnectedSpeed,
@@ -2044,7 +2176,9 @@ export default function HomePage() {
scheme.id === savedScheme.id ? { ...scheme, ...savedScheme } : scheme
)));
setSchemeDescription(savedScheme.description || '');
if (savedScheme.id === SCHEME_TWO_ID) {
if (savedScheme.id === DEFAULT_SCHEME_ID) {
setSchemeOneParams(schemeOneParamsToState(savedScheme.parameters));
} else if (savedScheme.id === SCHEME_TWO_ID) {
setSchemeTwoParams(schemeParamsToState(savedScheme.parameters));
}
setSchemeMessage('已保存');
@@ -2142,6 +2276,26 @@ 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 ||
['wind_speed_change_threshold', 'iqr_lower_multiplier', 'iqr_upper_multiplier',
'minimum_generator_speed', 'generator_speed_k'].some((field) => (
!Number.isFinite(schemeOneValues[field]) || schemeOneValues[field] < 0
));
if (!isSchemeTwo && schemeOneInvalid) {
setError('方案一参数必须为合法数值,额定与步长参数、叶轮半径和传动比必须大于 0');
return;
}
const gridConnectedSpeed = normalizeNumber(schemeTwoParams.grid_connected_speed);
const ratedGeneratorSpeed = normalizeNumber(schemeTwoParams.rated_generator_speed);
const ratedPower = normalizeNumber(schemeTwoParams.rated_power);
@@ -2166,13 +2320,14 @@ export default function HomePage() {
setProgress('正在准备标准化数据');
try {
const { rows } = buildStandardRows(files, mapping);
const { rows, rawRows } = buildStandardRows(files, mapping);
const start = await startWindJob({
files: files.map((file) => ({
file_name: file.file_name,
row_count: file.row_count,
})),
mapping,
raw_headers: files[0]?.raw_headers || files[0]?.headers || [],
});
jobId = start.job_id;
@@ -2184,6 +2339,7 @@ export default function HomePage() {
job_id: jobId,
chunk_index: index,
rows: chunkRows,
raw_rows: rawRows.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE),
});
}
@@ -2191,15 +2347,9 @@ export default function HomePage() {
const calculation = await finishWindJob({
job_id: jobId,
options: {
power_step: 5,
cleaning_wind_speed_step: 0.25,
curve_wind_speed_step: 0.5,
wind_speed_change_threshold: 1,
iqr_lower_multiplier: 1.8,
iqr_upper_multiplier: 2,
minimum_generator_speed: 1,
generator_speed_k: 0.9,
scheme_id: selectedSchemeId,
...(!isSchemeTwo ? schemeOneValues : {}),
...(isSchemeTwo ? {
grid_connected_speed: gridConnectedSpeed,
rated_generator_speed: ratedGeneratorSpeed,
@@ -2207,7 +2357,7 @@ export default function HomePage() {
} : {}),
},
});
setResult(calculation);
setResult({ ...calculation, job_id: jobId });
setSelectedFan(calculation.fans?.[0] || '');
setProgress('计算完成');
} catch (err) {
@@ -2218,7 +2368,11 @@ export default function HomePage() {
// 失败任务清理失败不影响用户看到主错误。
}
}
setError(err.message || '计算失败');
const message = err.message || '计算失败';
setError(message);
if (message.includes('服务器正在处理数据')) {
setReportExportNotice(message);
}
setProgress('');
} finally {
setSubmitting(false);
@@ -2248,6 +2402,19 @@ export default function HomePage() {
{error && <div className="alert error">{error}</div>}
{progress && <div className="alert info">{progress}</div>}
{reportExportNotice && (
<div className="dialogBackdrop" role="presentation">
<section className="appDialog" role="dialog" aria-modal="true" aria-labelledby="report-export-notice-title">
<h2 id="report-export-notice-title">提示</h2>
<p>{reportExportNotice}</p>
<div className="appDialogActions">
<button className="primaryButton" type="button" onClick={() => setReportExportNotice('')}>
我知道了
</button>
</div>
</section>
</div>
)}
<section className="panel schemePanel">
<div className="panelHeader">
@@ -2283,6 +2450,29 @@ export default function HomePage() {
/>
</label>
</div>
{!isSchemeTwo && (
<>
<p className="schemeParamHint">
叶尖速比 = 发电机转速 × 3.14 × 齿轮箱传动比 × 叶轮半径 × 30 ÷ 风速
</p>
<div className="schemeParamGrid">
{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)}
disabled={submitting || savingScheme}
placeholder="请输入"
/>
</label>
))}
</div>
</>
)}
{isSchemeTwo && (
<div className="schemeParamGrid">
<label className="fieldControl">
@@ -2457,7 +2647,7 @@ export default function HomePage() {
<div className="emptyState">上传文件后可指定列头对应关系</div>
) : (
<div className="mappingGrid">
{REQUIRED_FIELDS.map((field) => (
{mappingFields.map((field) => (
<label className="fieldControl" key={field.key}>
<span>{field.label}</span>
<select
@@ -2594,11 +2784,16 @@ export default function HomePage() {
className="secondaryButton"
type="button"
onClick={handleExportFullReport}
disabled={!canExportFullReport || exportingReport}
disabled={exportingReport}
title={reportExportDisabledReason || '导出当前风机的完整功率曲线报告'}
>
{exportingReport ? '导出报告中...' : '导出完整报告'}
{exportingReport ? (reportExportProgress || '导出报告中...') : '导出完整报告'}
</button>
{exportingReport && (
<button className="secondaryButton" type="button" onClick={handleCancelReportExport}>
取消导出
</button>
)}
<button
className="secondaryButton"
type="button"
+21
View File
@@ -141,3 +141,24 @@ export function deleteWindJob(jobId) {
method: 'DELETE',
});
}
/** 服务端生成完整报告并以文件流返回。 */
export async function downloadWindReport(jobId, payload) {
const resp = await fetch(`${BASE_URL}/wind/jobs/${encodeURIComponent(jobId)}/report`, {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text();
try {
const json = JSON.parse(text);
throw new Error(json.msg || '完整报告生成失败');
} catch (error) {
if (error instanceof SyntaxError) throw new Error('完整报告生成失败,请稍后重试');
throw error;
}
}
return resp.blob();
}