功能: 服务端生成完整报告并保护单任务
- 使用 libxlsxwriter 常量内存导出 Excel\n- 增加全局任务占用提示与原始数据持久化\n- 保留运行时任务数据并更新接口文档
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user