Реализация функционала настроек

This commit is contained in:
test 2025-07-02 17:38:09 +05:00
parent 11237605b9
commit b462a2b1ec
14 changed files with 466 additions and 243 deletions

View file

@ -5,6 +5,7 @@ import {buttonNames} from "../../../const/buttons/names";
import {ModalMap} from "../../modalwindows/windowsType";
import {IconComponent} from "../../../const/buttons/icons";
import {useModalContext} from "../../../store";
import {useNameSetting} from "../../../hooks/useSettings";
interface DeviceCardProps {
device: DeviceUser;
@ -74,8 +75,8 @@ const Value = styled.span`
const DeviceCard: React.FC<DeviceCardProps> = ({ device, status }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => setIsExpanded(!isExpanded);
const { openModal } = useModalContext();
const nameSetting = useNameSetting();
const openModalNote = (e: React.MouseEvent) => {
e.stopPropagation();
@ -87,7 +88,7 @@ const DeviceCard: React.FC<DeviceCardProps> = ({ device, status }) => {
return (
<CardWrapper status={status} onClick={toggleExpanded}>
<CardWrapper status={status} onClick={()=>setIsExpanded(!isExpanded)}>
<CardHeader status={status}>
<p>
MAC: {device.mac}
@ -100,7 +101,7 @@ const DeviceCard: React.FC<DeviceCardProps> = ({ device, status }) => {
<CardContent isExpanded={isExpanded}>
<InfoRow>
<Label>Имя:</Label>
<Value>{device.hostname || device.ip}</Value>
<Value>{device.hostname || (nameSetting === 'ip' ? device.ip : 'Неизвестно')}</Value>
</InfoRow>
<InfoRow>

View file

@ -1,10 +1,11 @@
import {Setting, useSettingsStore} from "../../store/settingsStore";
import {ChangeEvent} from "react";
import {Setting} from "../../store/settings/settingsStore";
import {useState} from "react";
import styled, {useTheme} from "styled-components";
import {IconComponent} from "../../const/buttons/icons";
import {buttonNames} from "../../const/buttons/names";
import SettingSelect from "./SettingSelect";
import {MainButton} from "../UI/buttons/mainButton";
import React from 'react';
const SettingBlock = styled.div`
display: flex;
@ -65,24 +66,55 @@ const LabelCheckbox = styled.label`
transform: translate(-50%, -50%);
}
`
const InputWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: end;
gap: 6px;
`
const ErrorText = styled.div`
color: red;
font-size: 12px;
`;
interface SettingItemProps {
setting: Setting;
value: any;
onChange: (value: any) => void;
onValidationError?: (error: string | null) => void;
}
export const SettingItem = ({ setting, value, onChange }: SettingItemProps) => {
const SettingItem = React.memo(({ setting, value, onChange, onValidationError }: SettingItemProps) => {
const theme = useTheme();
const { updateSetting } = useSettingsStore();
const [error, setError] = useState<string | null>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const newValue = setting.type === 'boolean'
? (e.target as HTMLInputElement).checked
: setting.type === 'number'
? Number(e.target.value)
: e.target.value;
onChange(newValue);
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
let newError: string | null = null;
if (inputValue.trim() === '') {
newError = 'Поле не может быть пустым';
onChange(null);
}
else if (isNaN(Number(inputValue))) {
newError = 'Пожалуйста, введите число';
}
else {
const numValue = Number(inputValue);
if (setting.min !== undefined && numValue < setting.min) {
newError = `Значение не может быть меньше ${setting.min}`;
}
else if (setting.max !== undefined && numValue > setting.max) {
newError = `Значение не может быть больше ${setting.max}`;
}
if (!newError) {
onChange(numValue);
}
}
setError(newError);
onValidationError?.(newError);
};
return (
@ -91,35 +123,48 @@ export const SettingItem = ({ setting, value, onChange }: SettingItemProps) => {
{setting.type === 'boolean' && (
<LabelCheckbox>
<StyledCheckbox />
<StyledCheckbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
/>
<IconComponent name={buttonNames[19]} width="18" height="18" setColor={theme.primary} />
</LabelCheckbox>
)}
{setting.type === 'select' && setting.options && (
<SettingSelect
value={setting.value}
value={value}
options={setting.options}
onChange={(value) => updateSetting(setting.id, value)}
onChange={(value) => onChange(value)}
/>
)}
{setting.type === 'text' && (
<input
type="text"
value={setting.value}
onChange={handleChange}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)}
{setting.type === 'number' && (
<input
type="number"
min={setting?.min}
max={setting?.max}
value={setting.value}
onChange={handleChange}
/>
<InputWrapper>
<input
type="number"
min={setting?.min}
max={setting?.max}
value={value === undefined || value === null ? '' : value}
onChange={handleNumberChange}
onKeyDown={(e) => {
if (!/[0-9]/.test(e.key) &&
!['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
}
}}
className={error ? 'error' : ''}
/>
{error && <ErrorText>{error}</ErrorText>}
</InputWrapper>
)}
{setting.type === 'button' && (
@ -127,8 +172,11 @@ export const SettingItem = ({ setting, value, onChange }: SettingItemProps) => {
type="button"
size="small"
title={setting.defaultValue || ''}
onClick={setting.action}
/>
)}
</SettingBlock>
);
};
});
export { SettingItem };

View file

@ -1,9 +1,8 @@
// components/modalContainers/NoteDevice.tsx
import { ModalContainer, ModalTitle, ModalContent, ButtonContainer } from "../modalwindows/style";
import { MainButton } from "../UI/buttons/mainButton";
import {useEffect, useMemo, useState} from "react";
import {useCallback, useEffect, useMemo, useState} from "react";
import styled from "styled-components";
import {Setting, useSettingsStore} from "../../store/settingsStore";
import {Setting, useSettingsStore} from "../../store/settings/settingsStore";
import {SettingItem} from "./SettingItem";
import {IconComponent} from "../../const/buttons/icons";
import {buttonNames} from "../../const/buttons/names";
@ -51,16 +50,31 @@ const SectionLine = styled.span`
const CloseBtn = styled.span`
cursor: pointer;
`
export const Settings = ({ onClose }: Props) => {
const settingsConfig = useSettingsStore(state => state.settingsConfig);
const settingsValues = useSettingsStore(state => state.settingsValues);
const activeSection = useSettingsStore(state => state.activeSection);
const pendingChanges = useSettingsStore(state => state.pendingChanges);
const setActiveSection = useSettingsStore(state => state.setActiveSection);
const updateSetting = useSettingsStore(state => state.updateSetting);
const applyChanges = useSettingsStore(state => state.applyChanges);
const discardChanges = useSettingsStore(state => state.discardChanges);
const getSetting = useSettingsStore(state => state.getSetting);
const initializeSettings = useSettingsStore(state => state.initializeSettings);
export const Settings = ({onClose}: Props) => {
const {
settingsConfig,
settingsValues,
activeSection,
setActiveSection,
initializeSettings,
updateSetting
} = useSettingsStore();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleValidationError = useCallback((fieldId: string, error: string | null) => {
setFieldErrors(prev => {
const newErrors = { ...prev };
if (error) {
newErrors[fieldId] = error;
} else {
delete newErrors[fieldId];
}
return newErrors;
});
}, []);
const settingsBySection = useMemo(() => {
return settingsConfig.reduce((acc, setting) => {
@ -75,7 +89,7 @@ export const Settings = ({onClose}: Props) => {
}, {} as Record<string, Setting[]>);
}, [settingsConfig, settingsValues]);
const sections = Object.keys(settingsBySection);
const sections = useMemo(() => Object.keys(settingsBySection), [settingsBySection]);
useEffect(() => {
initializeSettings();
@ -87,17 +101,40 @@ export const Settings = ({onClose}: Props) => {
}
}, [sections, activeSection, setActiveSection]);
const handleSave = () => {
onClose();
};
const handleSave = useCallback(() => {
const hasErrors = Object.values(fieldErrors).some(error => error?.trim());
if (hasErrors) {
alert('Исправьте ошибки перед сохранением');
return;
}
const requiredFields = settingsConfig.filter(s => s.required);
const emptyRequiredFields = requiredFields.filter(f => {
const value = pendingChanges[f.id] ?? settingsValues[f.id] ?? f.defaultValue;
return value == null || value === '';
});
if (emptyRequiredFields.length > 0) {
alert('Заполните все обязательные поля');
return;
}
applyChanges();
onClose();
}, [fieldErrors, settingsConfig, pendingChanges, settingsValues, applyChanges, onClose]);
const handleCancel = useCallback(() => {
discardChanges();
setFieldErrors({});
onClose();
}, [discardChanges, onClose]);
return (
<ModalContainer style={{ height: '60vh', width: '50vw'}}>
<ModalTitle>
<h3>Настройки</h3>
<CloseBtn>
<IconComponent name={buttonNames[12]} width="30" height="30" onClick={() => onClose()} />
<IconComponent name={buttonNames[12]} width="30" height="30" onClick={handleCancel} />
</CloseBtn>
</ModalTitle>
@ -121,7 +158,8 @@ export const Settings = ({onClose}: Props) => {
<SettingItem
key={setting.id}
setting={setting}
value={settingsValues[setting.id] ?? setting.defaultValue}
value={getSetting(setting.id).currentValue}
onValidationError={(error) => handleValidationError(setting.id, error)}
onChange={(value) => updateSetting(setting.id, value)}
/>
))}
@ -136,6 +174,7 @@ export const Settings = ({onClose}: Props) => {
size="small"
title='Сохранить'
onClick={handleSave}
/>
</ButtonContainer>
</ModalContainer>

View file

@ -1,7 +1,9 @@
import React, { useState } from 'react';
import React, {useEffect, useState} from 'react';
import styled from 'styled-components';
import DeviceCard from "../../../../../UI/Dropdown/DeviceCard";
import {DeviceUser} from "../../../../../../store/useDeviceStore";
import {usePaginateSetting} from "../../../../../../hooks/useSettings";
import Pagination from "../../../../../UI/Pagination";
const ScanBtn = styled.button`
position: relative;
@ -29,8 +31,29 @@ const ListUsers = ({loading, handleScan, devices, scanInitiated}: ListUsersProps
const inactiveDevices = devices.filter(device => device.status !== 'online');
// Пагинация
const paginateSetting = usePaginateSetting();
const [currentPage, setCurrentPage] = useState(1);
const [devicesPerPage] = useState(10);
const [devicesPerPage, setDevicesPerPage] = useState(paginateSetting);
useEffect(() => {
setDevicesPerPage(paginateSetting);
}, [paginateSetting]);
const indexOfLastDevice = currentPage * devicesPerPage;
const indexOfFirstDevice = indexOfLastDevice - devicesPerPage;
const currentActiveDevices = activeDevices.slice(indexOfFirstDevice, indexOfLastDevice);
const currentInactiveDevices = inactiveDevices.slice(indexOfFirstDevice, indexOfLastDevice);
const totalPages = Math.ceil(devices.length / devicesPerPage);
const handlePrevPage = () => {
if(currentPage > 1) setCurrentPage(currentPage - 1);
}
const handleNextPage = () => {
if(currentPage < totalPages) setCurrentPage(currentPage + 1);
}
const onPageChange = (page: number) => {
setCurrentPage(page);
}
return (
@ -48,7 +71,7 @@ const ListUsers = ({loading, handleScan, devices, scanInitiated}: ListUsersProps
{/* Колонка активных устройств */}
<div style={{ flex: 1 }}>
<h3 style={{ color: '#4CAF50', marginBottom:'12px' }}>Активные узлы ({activeDevices.length})</h3>
{activeDevices.map((device, index) => (
{currentActiveDevices.map((device, index) => (
<DeviceCard
key={index}
status="online"
@ -59,7 +82,7 @@ const ListUsers = ({loading, handleScan, devices, scanInitiated}: ListUsersProps
{/* Колонка неактивных устройств */}
<div style={{ flex: 1 }}>
<h3 style={{ color: '#ababab', marginBottom:'12px' }}>Неактивные узлы ({inactiveDevices.length})</h3>
{inactiveDevices.map((device, index) => (
{currentInactiveDevices.map((device, index) => (
<DeviceCard
key={index}
status="offline"
@ -71,6 +94,15 @@ const ListUsers = ({loading, handleScan, devices, scanInitiated}: ListUsersProps
) : scanInitiated ? (
<p style={{ marginTop: '20px' }}>Узлы не найдены</p>
) : null}
{totalPages > 1 && (
<Pagination
onPageChange={onPageChange}
currentPage={currentPage}
totalPages={totalPages}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
/>
)}
</>
);
};

View file

@ -10,7 +10,8 @@ import {buttonNames} from "../../../../../../const/buttons/names";
import {ModalMap} from "../../../../../modalwindows/windowsType";
import {useModalContext} from "../../../../../../store";
import Pagination from "../../../../../UI/Pagination";
import {useSettingsStore} from "../../../../../../store/settingsStore";
import {useSettingsStore} from "../../../../../../store/settings/settingsStore";
import {useNameSetting, usePaginateSetting} from "../../../../../../hooks/useSettings";
const TableContainer = styled.div`
width: 100%;
@ -74,14 +75,10 @@ const TableScanner = ({devices}:TableScannerProps) => {
currentNote: device.note || ''
});
};
const paginateSetting = useSettingsStore(state =>
state.settingsValues['paginate-nodes'] ??
state.settingsConfig.find(s => s.id === 'paginate-nodes')?.defaultValue ??
15
);
const nameSetting = useNameSetting();
// Пагинация
const paginateSetting = usePaginateSetting();
const [currentPage, setCurrentPage] = useState(1);
const [devicesPerPage, setDevicesPerPage] = useState(paginateSetting);
@ -123,7 +120,7 @@ const TableScanner = ({devices}:TableScannerProps) => {
{currentDevices.map((item, i) => (
<TableRow key={i}>
<TableCell><StatusBadge status={item.status}></StatusBadge></TableCell>
<TableCell>{item.hostname || item.ip}</TableCell>
<TableCell>{item.hostname || (nameSetting === 'ip' ? item.ip : 'Неизвестно')}</TableCell>
<TableCell>{item.vendor}</TableCell>
<TableCell>{item.ip}</TableCell>
<TableCell>{item.open_ports.join(', ') || '-'}</TableCell>

View file

@ -20,7 +20,7 @@ const ScanBtn = styled.button`
interface ListUsersProps {
loading: boolean;
handleScan: ()=>void;
handleScan: (replay?: boolean) => void;
devices: DeviceUser[];
scanInitiated: boolean;
}
@ -29,7 +29,7 @@ const ListUsers = ({loading, handleScan, devices, scanInitiated}: ListUsersProps
return (
<>
<ScanBtn onClick={handleScan} disabled={loading}>
<ScanBtn onClick={()=>handleScan()} disabled={loading}>
{devices.length > 0 ? 'Обновить список' : 'Сканировать узлы'}
</ScanBtn>

View file

@ -1,11 +1,12 @@
import { useIpStore } from "../../../../../store";
import styled, { keyframes } from "styled-components";
import { useState } from "react";
import {useEffect, useState} from "react";
import { NetworkService } from "../../../../../api/request";
import ListUsers from "./modules/ListUsers";
import TableUsers from "./modules/TableUsers";
import DropdownType from "../../../../UI/Dropdown/DropdownType";
import useNetworkStore from "../../../../../store/useDeviceStore";
import {useIntervalBoolean, useIntervalTime} from "../../../../../hooks/useSettings";
type Option ={
value: number,
@ -136,9 +137,11 @@ export default function UserScanner() {
const [scanInitiated, setScanInitiated] = useState(devices.length > 0);
// Сканирование
const handleScan = async () => {
const handleScan = async (replay:boolean) => {
try {
setLoading(true);
if(!replay){
setLoading(true);
}
setScanInitiated(true);
const res = await NetworkService.scanUser(ipAddress);
console.log(res);
@ -151,6 +154,23 @@ export default function UserScanner() {
}
};
// Интервально сканирование
const intervalToggle = useIntervalBoolean();
const intervalTime = useIntervalTime();
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
if (intervalToggle) {
intervalId = setInterval(()=>handleScan(true), intervalTime * 60 * 1000);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [intervalToggle, intervalTime, handleScan]);
// Способ отображения
const viewOptions: Option[] = [
{ value: 1, label: 'Таблица' },
@ -180,9 +200,9 @@ export default function UserScanner() {
Сканирование узлов <ScanningText />
</LoadingText>
) : selectedView.value == 1 ? (
<TableUsers loading={loading} handleScan={handleScan} devices={devices} scanInitiated={scanInitiated} />
<TableUsers loading={loading} handleScan={()=>handleScan(false)} devices={devices} scanInitiated={scanInitiated} />
):(
<ListUsers loading={loading} handleScan={handleScan} devices={devices} scanInitiated={scanInitiated} />
<ListUsers loading={loading} handleScan={()=>handleScan(false)} devices={devices} scanInitiated={scanInitiated} />
)}
</Wrapper>
);