复现已有算法
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,39 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
eslintPluginReactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>风电功率计算平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2763
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "wind_power_web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"uplot": "^1.6.32",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#6366f1"/><path d="M16 7l8 14H8z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 151 B |
@@ -0,0 +1,421 @@
|
||||
.home {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 56px;
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.homeHeader h1 {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.homeHeader .subtitle {
|
||||
margin-top: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.uploadButton,
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #6366f1;
|
||||
border-radius: 8px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.uploadButton:hover,
|
||||
.primaryButton:hover {
|
||||
background: #5558e8;
|
||||
border-color: #5558e8;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
min-height: 34px;
|
||||
border-color: #343449;
|
||||
background: #1a1a27;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
border-color: #6366f1;
|
||||
background: #24243a;
|
||||
}
|
||||
|
||||
.uploadButton input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.uploadButton.disabled,
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 18px;
|
||||
background: #14141e;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.workspace .panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panelHeader h2 {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.panelHeader span,
|
||||
.muted {
|
||||
color: #8f96a3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 14px 0;
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.alert.error,
|
||||
.error {
|
||||
border: 1px solid rgba(248, 113, 113, 0.36);
|
||||
background: rgba(127, 29, 29, 0.22);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.alert.info {
|
||||
border: 1px solid rgba(99, 102, 241, 0.36);
|
||||
background: rgba(49, 46, 129, 0.24);
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.emptyState,
|
||||
.emptyChart {
|
||||
display: flex;
|
||||
min-height: 96px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #343449;
|
||||
border-radius: 8px;
|
||||
color: #8f96a3;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fileList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
background: #1a1a27;
|
||||
}
|
||||
|
||||
.fileItem strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #e5e7eb;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileItem span {
|
||||
flex: 0 0 auto;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mappingGrid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fieldControl {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fieldControl span {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid #343449;
|
||||
border-radius: 7px;
|
||||
background: #0f0f18;
|
||||
color: #e5e7eb;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: #6366f1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.headerPreview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerPreview span {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #343449;
|
||||
border-radius: 999px;
|
||||
background: #1a1a27;
|
||||
color: #d1d5db;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resultSection {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
background: #1a1a27;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reasonList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.reasonList span {
|
||||
border-radius: 6px;
|
||||
background: #202033;
|
||||
color: #aeb7c4;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fanSelect {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.uplotWrap {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.uplot {
|
||||
width: 100% !important;
|
||||
color: #cbd5e1;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.uplot canvas {
|
||||
background: #10101a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.uplot .u-title,
|
||||
.uplot .u-label,
|
||||
.uplot .u-value {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.uplot .u-legend {
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.uplot .u-legend .u-series > * {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.chartTools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chartTools span {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #242437;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #1a1a27;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #d1d5db;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.confidence {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.confidence.ok {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.confidence.low {
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.home {
|
||||
padding: 24px 16px 42px;
|
||||
}
|
||||
|
||||
.homeHeader,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.fieldControl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import HomePage from './pages/HomePage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* SPA 回退:未匹配路由统一回到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,36 @@
|
||||
/* CSS Reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0d0d14;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0d0d14;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2a3a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a4a;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,636 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
deleteWindJob,
|
||||
finishWindJob,
|
||||
startWindJob,
|
||||
uploadWindChunk,
|
||||
} from '../utils/api';
|
||||
|
||||
const REQUIRED_FIELDS = [
|
||||
{ key: 'time', label: '时间' },
|
||||
{ key: 'fan_id', label: '风机编号' },
|
||||
{ key: 'wind_speed', label: '风速' },
|
||||
{ key: 'active_power', label: '有功功率' },
|
||||
{ key: 'generator_speed', label: '发电机转速' },
|
||||
];
|
||||
|
||||
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'],
|
||||
};
|
||||
|
||||
const FIELD_EXCLUDES = {
|
||||
active_power: ['限功率', '限电', '时间', '累计'],
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 4000;
|
||||
|
||||
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 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;
|
||||
}
|
||||
const text = String(value ?? '').trim().replace(/,/g, '');
|
||||
if (!text) return null;
|
||||
const number = Number(text);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function chooseFirstDataSheet(workbook) {
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
|
||||
const nonEmptyRows = rows.filter((row) => row.some((cell) => String(cell ?? '').trim() !== ''));
|
||||
if (nonEmptyRows.length > 1) {
|
||||
return { sheetName, rows: nonEmptyRows };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readExcelFile(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetData = chooseFirstDataSheet(workbook);
|
||||
if (!sheetData) {
|
||||
throw new Error(`${file.name} 没有可读取的数据工作表`);
|
||||
}
|
||||
|
||||
const headers = sheetData.rows[0].map((cell) => String(cell ?? '').trim());
|
||||
const dataRows = sheetData.rows.slice(1);
|
||||
return {
|
||||
id: `${file.name}_${file.size}_${file.lastModified}`,
|
||||
file_name: file.name,
|
||||
sheet_name: sheetData.sheetName,
|
||||
headers,
|
||||
rows: dataRows,
|
||||
row_count: dataRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getCell(row, headerIndex) {
|
||||
if (headerIndex < 0) return '';
|
||||
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 = [];
|
||||
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) {
|
||||
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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, indexes };
|
||||
}
|
||||
|
||||
function SummaryCards({ summary }) {
|
||||
if (!summary) return null;
|
||||
const cards = [
|
||||
['原始行数', summary.raw_rows],
|
||||
['有效行数', summary.valid_rows],
|
||||
['剔除行数', summary.invalid_rows],
|
||||
['风机数量', summary.fan_count],
|
||||
];
|
||||
return (
|
||||
<div className="summaryGrid">
|
||||
{cards.map(([label, value]) => (
|
||||
<div className="metric" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{Number(value || 0).toLocaleString()}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerCurveChart({ points, scatterPoints, filteredPoints, showFiltered }) {
|
||||
const chartRef = useRef(null);
|
||||
const validPoints = useMemo(
|
||||
() => (points || []).filter((point) => point.sample_count > 0),
|
||||
[points],
|
||||
);
|
||||
const validScatter = useMemo(
|
||||
() => (scatterPoints || []).filter((point) =>
|
||||
Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)),
|
||||
[scatterPoints],
|
||||
);
|
||||
const validFiltered = useMemo(
|
||||
() => (filteredPoints || []).filter((point) =>
|
||||
Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)),
|
||||
[filteredPoints],
|
||||
);
|
||||
|
||||
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 maxScatterPower = validScatter.reduce(
|
||||
(max, point) => Math.max(max, point.active_power),
|
||||
0,
|
||||
);
|
||||
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 width = Math.max(chartRef.current.clientWidth || 760, 320);
|
||||
|
||||
chartRef.current.innerHTML = '';
|
||||
const chart = new uPlot({
|
||||
width,
|
||||
height: 360,
|
||||
cursor: {
|
||||
drag: { x: true, y: true },
|
||||
},
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: { range: [0, maxPower * 1.08] },
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
label: '风速 (m/s)',
|
||||
stroke: '#9ca3af',
|
||||
grid: { stroke: '#2a2a3a', width: 1 },
|
||||
},
|
||||
{
|
||||
label: '功率 (kW)',
|
||||
stroke: '#9ca3af',
|
||||
grid: { stroke: '#2a2a3a', width: 1 },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
label: '分箱平均功率',
|
||||
stroke: '#60a5fa',
|
||||
width: 2,
|
||||
points: { show: true, size: 6, fill: '#93c5fd', stroke: '#0f172a' },
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
draw: [
|
||||
(u) => {
|
||||
const { ctx } = u;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(251, 191, 36, 0.28)';
|
||||
for (const point of validScatter) {
|
||||
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)) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
if (showFiltered) {
|
||||
ctx.fillStyle = 'rgba(248, 113, 113, 0.42)';
|
||||
for (const point of validFiltered) {
|
||||
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)) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
}, [curveX, curveY], chartRef.current);
|
||||
|
||||
return () => chart.destroy();
|
||||
}, [validPoints, validScatter, validFiltered, showFiltered]);
|
||||
|
||||
if (!validPoints.length) {
|
||||
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
|
||||
}
|
||||
|
||||
return <div className="uplotWrap" ref={chartRef} />;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [mapping, setMapping] = useState({});
|
||||
const [reading, setReading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [selectedFan, setSelectedFan] = useState('');
|
||||
const [showFiltered, setShowFiltered] = useState(false);
|
||||
|
||||
const headers = files[0]?.headers || [];
|
||||
const missingFields = REQUIRED_FIELDS.filter((field) => !mapping[field.key]);
|
||||
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
|
||||
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
|
||||
const selectedScatter = selectedFan && result?.scatter_points
|
||||
? result.scatter_points[selectedFan] || []
|
||||
: [];
|
||||
const selectedFiltered = selectedFan && result?.filtered_points
|
||||
? result.filtered_points[selectedFan] || []
|
||||
: [];
|
||||
|
||||
const totalRows = useMemo(
|
||||
() => files.reduce((sum, file) => sum + file.row_count, 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
async function handleFilesChange(event) {
|
||||
const selectedFiles = Array.from(event.target.files || []);
|
||||
if (!selectedFiles.length) return;
|
||||
|
||||
setReading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setSelectedFan('');
|
||||
setShowFiltered(false);
|
||||
try {
|
||||
const parsedFiles = [];
|
||||
for (const file of selectedFiles) {
|
||||
parsedFiles.push(await readExcelFile(file));
|
||||
}
|
||||
setFiles(parsedFiles);
|
||||
setMapping(inferMapping(parsedFiles[0].headers));
|
||||
} catch (err) {
|
||||
setError(err.message || '读取 Excel 失败');
|
||||
} finally {
|
||||
setReading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!files.length || missingFields.length) {
|
||||
setError('请先上传文件并完成所有字段映射');
|
||||
return;
|
||||
}
|
||||
|
||||
let jobId = '';
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setShowFiltered(false);
|
||||
setProgress('正在准备标准化数据');
|
||||
|
||||
try {
|
||||
const { rows } = buildStandardRows(files, mapping);
|
||||
const start = await startWindJob({
|
||||
files: files.map((file) => ({
|
||||
file_name: file.file_name,
|
||||
row_count: file.row_count,
|
||||
})),
|
||||
mapping,
|
||||
});
|
||||
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}`);
|
||||
await uploadWindChunk({
|
||||
job_id: jobId,
|
||||
chunk_index: index,
|
||||
rows: chunkRows,
|
||||
});
|
||||
}
|
||||
|
||||
setProgress('正在清洗数据并计算功率曲线');
|
||||
const calculation = await finishWindJob({
|
||||
job_id: jobId,
|
||||
options: {
|
||||
rated_power: 4800,
|
||||
rated_wind_speed: 18,
|
||||
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,
|
||||
},
|
||||
});
|
||||
setResult(calculation);
|
||||
setSelectedFan(calculation.fans?.[0] || '');
|
||||
setProgress('计算完成');
|
||||
} catch (err) {
|
||||
if (jobId) {
|
||||
try {
|
||||
await deleteWindJob(jobId);
|
||||
} catch {
|
||||
// 失败任务清理失败不影响用户看到主错误。
|
||||
}
|
||||
}
|
||||
setError(err.message || '计算失败');
|
||||
setProgress('');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<header className="homeHeader">
|
||||
<div>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{progress && <div className="alert info">{progress}</div>}
|
||||
|
||||
<section className="workspace">
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>1. 数据文件</h2>
|
||||
<span>{files.length ? `${files.length} 个文件 / ${totalRows.toLocaleString()} 行` : '等待上传'}</span>
|
||||
</div>
|
||||
{!files.length ? (
|
||||
<div className="emptyState">请选择一个或多个 Excel 文件,系统会读取首个非空工作表。</div>
|
||||
) : (
|
||||
<div className="fileList">
|
||||
{files.map((file) => (
|
||||
<div className="fileItem" key={file.id}>
|
||||
<strong>{file.file_name}</strong>
|
||||
<span>{file.sheet_name} · {file.row_count.toLocaleString()} 行</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>2. 字段映射</h2>
|
||||
<span>{missingFields.length ? `缺少 ${missingFields.length} 项` : '已完成'}</span>
|
||||
</div>
|
||||
{!headers.length ? (
|
||||
<div className="emptyState">上传文件后可指定列头对应关系。</div>
|
||||
) : (
|
||||
<div className="mappingGrid">
|
||||
{REQUIRED_FIELDS.map((field) => (
|
||||
<label className="fieldControl" key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<select
|
||||
value={mapping[field.key] || ''}
|
||||
onChange={(event) => setMapping((prev) => ({
|
||||
...prev,
|
||||
[field.key]: event.target.value,
|
||||
}))}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">请选择列头</option>
|
||||
{headers.map((header, index) => (
|
||||
<option value={header} key={`${header}-${index}`}>{header || `未命名列 ${index + 1}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="actionRow">
|
||||
<button
|
||||
className="primaryButton"
|
||||
type="button"
|
||||
disabled={!files.length || missingFields.length > 0 || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? '计算中' : '开始计算'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>3. 列头预览</h2>
|
||||
<span>{headers.length ? `${headers.length} 列` : '暂无'}</span>
|
||||
</div>
|
||||
{headers.length ? (
|
||||
<div className="headerPreview">
|
||||
{headers.map((header, index) => (
|
||||
<span key={`${header}-${index}`}>{header || `未命名列 ${index + 1}`}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="emptyState">上传后展示首个文件的列头。</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{result && (
|
||||
<section className="resultSection">
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>4. 清洗结果</h2>
|
||||
<span>计算完成</span>
|
||||
</div>
|
||||
<SummaryCards summary={result.summary} />
|
||||
<div className="reasonList">
|
||||
{Object.entries(result.summary?.invalid_reasons || {}).map(([reason, count]) => (
|
||||
<span key={reason}>{reason}: {Number(count).toLocaleString()}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>功率曲线</h2>
|
||||
<div className="chartTools">
|
||||
<span>
|
||||
曲线 {selectedPoints.length} 点 · 散点 {selectedScatter.length.toLocaleString()} 点
|
||||
· 滤除 {selectedFiltered.length.toLocaleString()} 点
|
||||
</span>
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
onClick={() => setShowFiltered((prev) => !prev)}
|
||||
disabled={!selectedFiltered.length}
|
||||
>
|
||||
{showFiltered ? '隐藏滤除点' : '显示滤除点'}
|
||||
</button>
|
||||
<select
|
||||
className="fanSelect"
|
||||
value={selectedFan}
|
||||
onChange={(event) => setSelectedFan(event.target.value)}
|
||||
>
|
||||
{(result.fans || []).map((fan) => (
|
||||
<option key={fan} value={fan}>{fan}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<PowerCurveChart
|
||||
points={selectedPoints}
|
||||
scatterPoints={selectedScatter}
|
||||
filteredPoints={selectedFiltered}
|
||||
showFiltered={showFiltered}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<h2>分箱明细</h2>
|
||||
<span>{selectedBins.length} 个区间</span>
|
||||
</div>
|
||||
<div className="tableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>风速区间</th>
|
||||
<th>平均功率</th>
|
||||
<th>中位数</th>
|
||||
<th>样本数</th>
|
||||
<th>标准差</th>
|
||||
<th>置信度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedBins.map((bin) => (
|
||||
<tr key={`${bin.wind_speed_start}-${bin.wind_speed_end}`}>
|
||||
<td>{bin.wind_speed_start.toFixed(2)} - {bin.wind_speed_end.toFixed(2)}</td>
|
||||
<td>{bin.average_power.toFixed(1)}</td>
|
||||
<td>{bin.median_power.toFixed(1)}</td>
|
||||
<td>{bin.sample_count}</td>
|
||||
<td>{bin.stddev_power.toFixed(1)}</td>
|
||||
<td><span className={`confidence ${bin.confidence === '正常' ? 'ok' : 'low'}`}>{bin.confidence}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file api.js
|
||||
* @description REST API 封装(原生 fetch + 统一响应信封 {status,msg,data})
|
||||
* 参考: edge_collector/frontend/cloud_app/src/utils/api.js(精简版,无鉴权)
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
/**
|
||||
* 通用请求封装
|
||||
* @param {string} url - 请求路径(相对 /api)
|
||||
* @param {object} options - fetch 选项
|
||||
* @returns {Promise<object>} 响应 data 字段
|
||||
*/
|
||||
async function request(url, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
const resp = await fetch(`${BASE_URL}${url}`, {
|
||||
cache: 'no-store',
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`服务器返回非法响应 (HTTP ${resp.status}),请检查后端服务状态`);
|
||||
}
|
||||
|
||||
// 业务失败
|
||||
if (json.status !== 0) {
|
||||
throw new Error(json.msg || '请求失败');
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务端版本信息
|
||||
* @returns {Promise<{name: string, version: string}>}
|
||||
*/
|
||||
export function getVersion() {
|
||||
return request('/system/version');
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export function getHealth() {
|
||||
return request('/system/health');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建风功率计算任务
|
||||
* @param {{files: Array<{file_name: string, row_count: number}>, mapping: object}} payload
|
||||
* @returns {Promise<{job_id: string}>}
|
||||
*/
|
||||
export function startWindJob(payload) {
|
||||
return request('/wind/jobs/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传标准化后的数据分片
|
||||
* @param {{job_id: string, chunk_index: number, rows: Array<object>}} payload
|
||||
* @returns {Promise<{accepted_rows: number}>}
|
||||
*/
|
||||
export function uploadWindChunk(payload) {
|
||||
return request('/wind/jobs/chunk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务并计算功率曲线
|
||||
* @param {{job_id: string, options: object}} payload
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export function finishWindJob(payload) {
|
||||
return request('/wind/jobs/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未完成的计算任务
|
||||
* @param {string} jobId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function deleteWindJob(jobId) {
|
||||
return request(`/wind/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8848',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user