更新项目 skills 并完善设计功率曲线前端

- skills: 移除 edge_collector 专属技能(OTA/协议/边缘主机/collector 等),
  保留通用工程规范(analyze-questions/cpp-coding-style/git-commit/shell-scripting/
  frontend-debug/frontend-dialog/frontend-ui-conventions),新增 wind_power_cal
  项目技能(wind-project-overview/build-run/deploy/backend-conventions/
  frontend-conventions/third-party-libs)与元技能 skill-builder;
  .agents/skills 与 .claude/skills 保持同步
- 前端: HomePage/App.css 增加设计功率曲线录入与图表展示

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cloud
2026-07-14 17:50:23 +08:00
parent ce58c35c34
commit 3cb7684039
131 changed files with 1026 additions and 8441 deletions
+329 -24
View File
@@ -29,7 +29,27 @@ const FIELD_EXCLUDES = {
active_power: ['限功率', '限电', '时间', '累计'],
};
const DESIGN_FIELDS = [
{ key: 'wind_speed', label: '风速' },
{ key: 'design_power', label: '设计功率' },
];
const DESIGN_FIELD_HINTS = {
wind_speed: ['风速', 'wind speed', 'windspeed', 'm/s'],
design_power: ['设计功率', '理论功率', '标准功率', '功率', 'power', 'kw'],
};
const CHUNK_SIZE = 4000;
const CHART_HEIGHT = 540;
function createDesignRows(points) {
const source = points?.length ? points : [{ wind_speed: '', design_power: '' }];
return source.map((point, index) => ({
id: `${Date.now()}_${index}_${Math.random().toString(16).slice(2)}`,
wind_speed: point.wind_speed ?? '',
design_power: point.design_power ?? '',
}));
}
function normalizeHeader(value) {
return String(value ?? '')
@@ -75,6 +95,41 @@ function inferMapping(headers) {
return mapping;
}
function inferDesignMapping(headers) {
const normalized = headers.map((header) => ({
header,
normalized: normalizeHeader(header),
}));
const mapping = {};
for (const field of DESIGN_FIELDS) {
const hints = DESIGN_FIELD_HINTS[field.key].map(normalizeHeader);
let best = null;
for (const item of normalized) {
if (!item.normalized) {
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 excelSerialToDate(value) {
const days = Number(value);
if (!Number.isFinite(days) || days <= 0) {
@@ -168,6 +223,49 @@ async function readExcelFile(file) {
};
}
async function readDesignPowerFile(file) {
const parsedFile = await readExcelFile(file);
const mapping = inferDesignMapping(parsedFile.headers);
const windSpeedIndex = parsedFile.headers.indexOf(mapping.wind_speed);
const designPowerIndex = parsedFile.headers.indexOf(mapping.design_power);
if (windSpeedIndex < 0 || designPowerIndex < 0) {
throw new Error('设计功率文件未识别到风速列或设计功率列');
}
const grouped = new Map();
for (const row of parsedFile.rows) {
const windSpeed = normalizeNumber(getCell(row, windSpeedIndex));
const designPower = normalizeNumber(getCell(row, designPowerIndex));
if (!Number.isFinite(windSpeed) || !Number.isFinite(designPower) ||
windSpeed < 0 || designPower < 0) {
continue;
}
const key = windSpeed.toFixed(6);
const current = grouped.get(key) || { wind_speed: windSpeed, sum: 0, count: 0 };
current.sum += designPower;
current.count += 1;
grouped.set(key, current);
}
const points = Array.from(grouped.values())
.map((item) => ({
wind_speed: item.wind_speed,
design_power: item.sum / item.count,
}))
.sort((left, right) => left.wind_speed - right.wind_speed);
if (!points.length) {
throw new Error('设计功率文件没有可用的风速-功率数据');
}
return {
file_name: parsedFile.file_name,
sheet_name: parsedFile.sheet_name,
points,
};
}
function getCell(row, headerIndex) {
if (headerIndex < 0) return '';
return row[headerIndex];
@@ -218,7 +316,13 @@ function SummaryCards({ summary }) {
);
}
function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }) {
function PowerCurveChart({
points,
scatterPoints,
filteredPoints,
showFiltered,
designPoints,
}) {
const chartRef = useRef(null);
const validPoints = useMemo(
() => (points || []).filter((point) => point.sample_count > 0),
@@ -234,13 +338,29 @@ function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }
Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)),
[filteredPoints],
);
const validDesign = useMemo(
() => (designPoints || []).filter((point) =>
Number.isFinite(point.wind_speed) && Number.isFinite(point.design_power)),
[designPoints],
);
useEffect(() => {
if (!chartRef.current || !validPoints.length) return undefined;
const sortedCurve = [...validPoints].sort((left, right) => left.wind_speed - right.wind_speed);
const curveX = sortedCurve.map((point) => point.wind_speed);
const curveY = sortedCurve.map((point) => point.average_power);
const sortedDesign = [...validDesign].sort((left, right) => left.wind_speed - right.wind_speed);
const actualByWindSpeed = new Map(
sortedCurve.map((point) => [point.wind_speed.toFixed(6), point.average_power]),
);
const designByWindSpeed = new Map(
sortedDesign.map((point) => [point.wind_speed.toFixed(6), point.design_power]),
);
const curveX = Array.from(new Set([
...sortedCurve.map((point) => point.wind_speed),
...sortedDesign.map((point) => point.wind_speed),
])).sort((left, right) => left - right);
const actualY = curveX.map((windSpeed) => actualByWindSpeed.get(windSpeed.toFixed(6)) ?? null);
const designY = curveX.map((windSpeed) => designByWindSpeed.get(windSpeed.toFixed(6)) ?? null);
const maxScatterPower = validScatter.reduce(
(max, point) => Math.max(max, point.active_power),
0,
@@ -248,14 +368,21 @@ function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }
const maxFilteredPower = showFiltered
? validFiltered.reduce((max, point) => Math.max(max, point.active_power), 0)
: 0;
const maxCurvePower = curveY.reduce((max, value) => Math.max(max, value), 0);
const maxPower = Math.max(maxCurvePower, maxScatterPower, maxFilteredPower, 1);
const maxCurvePower = sortedCurve.reduce(
(max, point) => Math.max(max, point.average_power),
0,
);
const maxDesignPower = sortedDesign.reduce(
(max, point) => Math.max(max, point.design_power),
0,
);
const maxPower = Math.max(maxCurvePower, maxDesignPower, maxScatterPower, maxFilteredPower, 1);
const width = Math.max(chartRef.current.clientWidth || 760, 320);
chartRef.current.innerHTML = '';
const chart = new uPlot({
width,
height: 360,
height: CHART_HEIGHT,
cursor: {
drag: { x: true, y: true },
},
@@ -278,14 +405,22 @@ function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }
series: [
{},
{
label: '分箱平均功率',
stroke: '#60a5fa',
width: 2,
points: { show: true, size: 6, fill: '#93c5fd', stroke: '#0f172a' },
label: '实际功率',
stroke: '#ef4444',
width: 2.5,
paths: uPlot.paths.spline(),
points: { show: false },
},
{
label: '设计功率',
stroke: '#22c55e',
width: 2.2,
paths: uPlot.paths.spline(),
points: { show: false },
},
],
hooks: {
draw: [
drawAxes: [
(u) => {
const { ctx } = u;
ctx.save();
@@ -315,16 +450,25 @@ function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }
},
],
},
}, [curveX, curveY], chartRef.current);
}, [curveX, actualY, designY], chartRef.current);
return () => chart.destroy();
}, [validPoints, validScatter, validFiltered, showFiltered]);
}, [validPoints, validScatter, validFiltered, validDesign, showFiltered]);
if (!validPoints.length) {
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
}
return <div className="uplotWrap" ref={chartRef} />;
return (
<div className="chartFrame">
<div className="chartLegend">
<span><i className="legendDot scatter" />散点数据</span>
<span><i className="legendLine actual" />实际功率</span>
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
</div>
<div className="uplotWrap" ref={chartRef} />
</div>
);
}
export default function HomePage() {
@@ -337,6 +481,9 @@ export default function HomePage() {
const [result, setResult] = useState(null);
const [selectedFan, setSelectedFan] = useState('');
const [showFiltered, setShowFiltered] = useState(false);
const [readingDesign, setReadingDesign] = useState(false);
const [designRows, setDesignRows] = useState(() => createDesignRows());
const [designMeta, setDesignMeta] = useState(null);
const headers = files[0]?.headers || [];
const missingFields = REQUIRED_FIELDS.filter((field) => !mapping[field.key]);
@@ -356,6 +503,28 @@ export default function HomePage() {
() => files.reduce((sum, file) => sum + file.row_count, 0),
[files],
);
const designCurve = useMemo(() => {
const grouped = new Map();
for (const row of designRows) {
const windSpeed = normalizeNumber(row.wind_speed);
const designPower = normalizeNumber(row.design_power);
if (!Number.isFinite(windSpeed) || !Number.isFinite(designPower) ||
windSpeed < 0 || designPower < 0) {
continue;
}
const key = windSpeed.toFixed(6);
const current = grouped.get(key) || { wind_speed: windSpeed, sum: 0, count: 0 };
current.sum += designPower;
current.count += 1;
grouped.set(key, current);
}
return Array.from(grouped.values())
.map((item) => ({
wind_speed: item.wind_speed,
design_power: item.sum / item.count,
}))
.sort((left, right) => left.wind_speed - right.wind_speed);
}, [designRows]);
async function handleFilesChange(event) {
const selectedFiles = Array.from(event.target.files || []);
@@ -381,6 +550,56 @@ export default function HomePage() {
}
}
async function handleDesignFileChange(event) {
const [file] = Array.from(event.target.files || []);
if (!file) return;
setReadingDesign(true);
setError('');
try {
const design = await readDesignPowerFile(file);
setDesignRows(createDesignRows(design.points));
setDesignMeta({
file_name: design.file_name,
sheet_name: design.sheet_name,
point_count: design.points.length,
});
} catch (err) {
setError(err.message || '读取设计功率失败');
} finally {
setReadingDesign(false);
event.target.value = '';
}
}
function handleDesignRowChange(rowId, field, value) {
setDesignRows((prev) => prev.map((row) => (
row.id === rowId ? { ...row, [field]: value } : row
)));
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
}
function handleAddDesignRow() {
setDesignRows((prev) => [
...prev,
...createDesignRows([{ wind_speed: '', design_power: '' }]),
]);
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
}
function handleDeleteDesignRow(rowId) {
setDesignRows((prev) => {
const next = prev.filter((row) => row.id !== rowId);
return next.length ? next : createDesignRows();
});
setDesignMeta((prev) => prev ? { ...prev, edited: true } : { edited: true });
}
function handleClearDesignRows() {
setDesignRows(createDesignRows());
setDesignMeta(null);
}
async function handleSubmit() {
if (!files.length || missingFields.length) {
setError('请先上传文件并完成所有字段映射');
@@ -455,21 +674,106 @@ export default function HomePage() {
<h1>风电功率计算平台</h1>
<p className="subtitle"> Excel 导入字段映射数据清洗与功率曲线计算</p>
</div>
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
<input
type="file"
accept=".xls,.xlsx"
multiple
disabled={reading || submitting}
onChange={handleFilesChange}
/>
{reading ? '解析中' : '上传 Excel'}
</label>
<div className="headerActions">
<label className={`uploadButton ${reading ? 'disabled' : ''}`}>
<input
type="file"
accept=".xls,.xlsx"
multiple
disabled={reading || submitting}
onChange={handleFilesChange}
/>
{reading ? '解析中' : '上传 Excel'}
</label>
</div>
</header>
{error && <div className="alert error">{error}</div>}
{progress && <div className="alert info">{progress}</div>}
<section className="panel designPowerPanel">
<div className="panelHeader">
<div>
<h2>设计功率曲线</h2>
<p className="panelHint">可手动填写风速和设计功率也可上传 Excel 自动加载后再修改</p>
</div>
<div className="designActions">
<span>{designCurve.length ? `${designCurve.length.toLocaleString()} 个有效点` : '未启用'}</span>
<label className={`secondaryButton fileButton ${readingDesign ? 'disabled' : ''}`}>
<input
type="file"
accept=".xls,.xlsx"
disabled={readingDesign}
onChange={handleDesignFileChange}
/>
{readingDesign ? '解析中' : '导入 Excel'}
</label>
<button className="secondaryButton" type="button" onClick={handleAddDesignRow}>
新增一行
</button>
<button className="secondaryButton" type="button" onClick={handleClearDesignRows}>
清空
</button>
</div>
</div>
{designMeta && (
<div className="designMeta">
<span>{designMeta.file_name || '手动填写'}</span>
{designMeta.sheet_name && <span>{designMeta.sheet_name}</span>}
{designMeta.point_count > 0 && <span>导入 {designMeta.point_count.toLocaleString()} </span>}
{designMeta.edited && <span>已手动修改</span>}
</div>
)}
<div className="designTableWrap">
<table className="designTable">
<thead>
<tr>
<th>风速 (m/s)</th>
<th>设计功率 (kW)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{designRows.map((row) => (
<tr key={row.id}>
<td>
<input
className="tableInput"
type="number"
min="0"
step="0.01"
value={row.wind_speed}
placeholder="例如 3.0"
onChange={(event) => handleDesignRowChange(row.id, 'wind_speed', event.target.value)}
/>
</td>
<td>
<input
className="tableInput"
type="number"
min="0"
step="0.1"
value={row.design_power}
placeholder="例如 120.0"
onChange={(event) => handleDesignRowChange(row.id, 'design_power', event.target.value)}
/>
</td>
<td>
<button
className="textButton"
type="button"
onClick={() => handleDeleteDesignRow(row.id)}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="workspace">
<div className="panel">
<div className="panelHeader">
@@ -610,6 +914,7 @@ export default function HomePage() {
scatterPoints={selectedScatter}
filteredPoints={selectedFiltered}
showFiltered={showFiltered}
designPoints={designCurve}
/>
</div>