rc #50
|
|
@ -28,7 +28,9 @@
|
||||||
"vite-plugin-svgr": "^4.3.0",
|
"vite-plugin-svgr": "^4.3.0",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"antd": "^5.24.7"
|
"antd": "^5.24.7",
|
||||||
|
"react-window": "1.8.11",
|
||||||
|
"react-virtualized-auto-sizer": "1.0.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
TextField, Box, Typography, IconButton, Divider,
|
TextField, Box, Typography, IconButton, Divider,
|
||||||
CircularProgress, Alert, Collapse, Tooltip, Button
|
CircularProgress, Alert, Collapse, Tooltip, Button
|
||||||
|
|
@ -7,6 +7,81 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { VariableSizeList as List } from 'react-window';
|
||||||
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
|
const MetricItem = React.memo(({ metric, index, updateRange, addRange, deleteRange }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
mb: 3,
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: 'background.paper'
|
||||||
|
}}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
|
{metric.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{metric.ranges.map((r, j) => (
|
||||||
|
<Box
|
||||||
|
key={j}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
mt: 1,
|
||||||
|
'& > *': { flex: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Минимум"
|
||||||
|
type="number"
|
||||||
|
value={r.min}
|
||||||
|
onChange={(e) => updateRange(index, j, 'min', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Максимум"
|
||||||
|
type="number"
|
||||||
|
value={r.max}
|
||||||
|
onChange={(e) => updateRange(index, j, 'max', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Статус"
|
||||||
|
type="number"
|
||||||
|
value={r.status}
|
||||||
|
onChange={(e) => updateRange(index, j, 'status', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Удалить диапазон">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => deleteRange(index, j)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 'none' }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => addRange(index)}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Добавить диапазон
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const MetricRangeEditor = ({ onSave }) => {
|
const MetricRangeEditor = ({ onSave }) => {
|
||||||
const [ranges, setRanges] = useState([]);
|
const [ranges, setRanges] = useState([]);
|
||||||
|
|
@ -17,7 +92,6 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
// Загрузка данных
|
|
||||||
const loadRanges = useCallback(async () => {
|
const loadRanges = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -41,31 +115,53 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
loadRanges();
|
loadRanges();
|
||||||
}, [loadRanges]);
|
}, [loadRanges]);
|
||||||
|
|
||||||
// Обновление диапазона
|
const updateRange = useCallback((metricIndex, rangeIndex, field, value) => {
|
||||||
const updateRange = (metricIndex, rangeIndex, field, value) => {
|
setRanges(prev => {
|
||||||
const newRanges = [...ranges];
|
const newRanges = [...prev];
|
||||||
newRanges[metricIndex].ranges[rangeIndex][field] = Number(value);
|
newRanges[metricIndex] = {
|
||||||
setRanges(newRanges);
|
...newRanges[metricIndex],
|
||||||
|
ranges: [...newRanges[metricIndex].ranges]
|
||||||
|
};
|
||||||
|
newRanges[metricIndex].ranges[rangeIndex] = {
|
||||||
|
...newRanges[metricIndex].ranges[rangeIndex],
|
||||||
|
[field]: Number(value)
|
||||||
|
};
|
||||||
|
return newRanges;
|
||||||
|
});
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemSize = (index) => {
|
||||||
|
const baseHeight = 80;
|
||||||
|
const rangeCount = filtered[index].ranges.length;
|
||||||
|
return baseHeight + rangeCount * 56 + 40;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавление диапазона
|
const addRange = useCallback((metricIndex) => {
|
||||||
const addRange = (metricIndex) => {
|
setRanges(prev => {
|
||||||
const newRanges = [...ranges];
|
const newRanges = [...prev];
|
||||||
newRanges[metricIndex].ranges.push({ min: 0, max: 100, status: 1 });
|
newRanges[metricIndex] = {
|
||||||
setRanges(newRanges);
|
...newRanges[metricIndex],
|
||||||
setHasChanges(true);
|
ranges: [...newRanges[metricIndex].ranges, { min: 0, max: 100, status: 1 }]
|
||||||
};
|
};
|
||||||
|
return newRanges;
|
||||||
// Удаление диапазона
|
});
|
||||||
const deleteRange = (metricIndex, rangeIndex) => {
|
|
||||||
const newRanges = [...ranges];
|
|
||||||
newRanges[metricIndex].ranges.splice(rangeIndex, 1);
|
|
||||||
setRanges(newRanges);
|
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const saveChanges = async () => {
|
const deleteRange = useCallback((metricIndex, rangeIndex) => {
|
||||||
|
setRanges(prev => {
|
||||||
|
const newRanges = [...prev];
|
||||||
|
newRanges[metricIndex] = {
|
||||||
|
...newRanges[metricIndex],
|
||||||
|
ranges: newRanges[metricIndex].ranges.filter((_, i) => i !== rangeIndex)
|
||||||
|
};
|
||||||
|
return newRanges;
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveChanges = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/ranges/update`, ranges);
|
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/ranges/update`, ranges);
|
||||||
|
|
@ -87,10 +183,9 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [ranges, onSave]);
|
||||||
|
|
||||||
// Добавление новой метрики
|
const addNewMetric = useCallback(() => {
|
||||||
const addNewMetric = () => {
|
|
||||||
if (!newMetricName.trim()) {
|
if (!newMetricName.trim()) {
|
||||||
setError('Введите название метрики');
|
setError('Введите название метрики');
|
||||||
return;
|
return;
|
||||||
|
|
@ -99,18 +194,26 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
setError('Метрика с таким именем уже существует');
|
setError('Метрика с таким именем уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRanges([...ranges, {
|
setRanges(prev => [...prev, {
|
||||||
name: newMetricName,
|
name: newMetricName,
|
||||||
ranges: [{ min: 0, max: 100, status: 1 }]
|
ranges: [{ min: 0, max: 100, status: 1 }]
|
||||||
}]);
|
}]);
|
||||||
setNewMetricName('');
|
setNewMetricName('');
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
}, [newMetricName, ranges]);
|
||||||
|
|
||||||
const filtered = filter
|
const filtered = useMemo(() => {
|
||||||
|
return filter
|
||||||
? ranges.filter(r => r.name.toLowerCase().includes(filter.toLowerCase()))
|
? ranges.filter(r => r.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
: ranges;
|
: ranges;
|
||||||
|
}, [filter, ranges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSave) {
|
||||||
|
onSave({ hasChanges, saveChanges });
|
||||||
|
}
|
||||||
|
}, [hasChanges, onSave, saveChanges]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
|
@ -166,76 +269,30 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
{filtered.map((metric, i) => (
|
<Box sx={{ height: '60vh', width: '100%' }}>
|
||||||
<Box
|
<AutoSizer>
|
||||||
key={metric.name}
|
{({ height, width }) => (
|
||||||
sx={{
|
<List
|
||||||
mb: 3,
|
height={height}
|
||||||
p: 2,
|
width={width}
|
||||||
border: '1px solid',
|
itemSize={getItemSize}
|
||||||
borderColor: 'divider',
|
itemCount={filtered.length}
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: 'background.paper'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
{({ index, style }) => (
|
||||||
{metric.name}
|
<Box style={style} sx={{ p: 1 }}>
|
||||||
</Typography>
|
<MetricItem
|
||||||
|
metric={filtered[index]}
|
||||||
{metric.ranges.map((r, j) => (
|
index={index}
|
||||||
<Box
|
updateRange={updateRange}
|
||||||
key={j}
|
addRange={addRange}
|
||||||
sx={{
|
deleteRange={deleteRange}
|
||||||
display: 'flex',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: 'center',
|
|
||||||
mt: 1,
|
|
||||||
'& > *': { flex: 1 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="Минимум"
|
|
||||||
type="number"
|
|
||||||
value={r.min}
|
|
||||||
onChange={(e) => updateRange(i, j, 'min', e.target.value)}
|
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
label="Максимум"
|
|
||||||
type="number"
|
|
||||||
value={r.max}
|
|
||||||
onChange={(e) => updateRange(i, j, 'max', e.target.value)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Статус"
|
|
||||||
type="number"
|
|
||||||
value={r.status}
|
|
||||||
onChange={(e) => updateRange(i, j, 'status', e.target.value)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<Tooltip title="Удалить диапазон">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => deleteRange(i, j)}
|
|
||||||
size="small"
|
|
||||||
sx={{ flex: 'none' }}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
)}
|
||||||
|
</List>
|
||||||
<Button
|
)}
|
||||||
onClick={() => addRange(i)}
|
</AutoSizer>
|
||||||
startIcon={<AddIcon />}
|
|
||||||
size="small"
|
|
||||||
sx={{ mt: 1 }}
|
|
||||||
>
|
|
||||||
Добавить диапазон
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<Typography color="text.secondary" textAlign="center" py={3}>
|
<Typography color="text.secondary" textAlign="center" py={3}>
|
||||||
|
|
@ -244,15 +301,8 @@ const MetricRangeEditor = ({ onSave }) => {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Передаем состояние изменений в родительский компонент */}
|
|
||||||
{useEffect(() => {
|
|
||||||
if (onSave) {
|
|
||||||
onSave({ hasChanges, saveChanges });
|
|
||||||
}
|
|
||||||
}, [hasChanges, onSave])}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MetricRangeEditor;
|
export default React.memo(MetricRangeEditor);
|
||||||
Loading…
Reference in New Issue