13.12.25 - начало разработки фронта
This commit is contained in:
78
src/App.tsx
Normal file
78
src/App.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { Routes, Route, Navigate, BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import ruRU from 'antd/locale/ru_RU';
|
||||
|
||||
import MainLayout from '@/components/layout/MainLayout';
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
|
||||
import { useUiStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getThemeConfig } from '@/theme/themeConfig';
|
||||
|
||||
// Настройка React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Компонент защиты роутов
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
// ReactNode валиден для возврата
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const themeMode = useUiStore((state) => state.themeMode);
|
||||
|
||||
// Применение темы к body для смены общего фона (за пределами React компонентов)
|
||||
useEffect(() => {
|
||||
const colorBgLayout = themeMode === 'dark' ? '#0a111b' : '#f0f5ff';
|
||||
document.body.style.backgroundColor = colorBgLayout;
|
||||
}, [themeMode]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider
|
||||
locale={ruRU}
|
||||
theme={getThemeConfig(themeMode)}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
{/* Вложенные роуты внутри MainLayout */}
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="tasks" element={<div>Задачи</div>} />
|
||||
<Route path="tickets" element={<div>Тикеты</div>} />
|
||||
<Route path="companies" element={<div>Компании</div>} />
|
||||
<Route path="servers" element={<div>Серверы</div>} />
|
||||
<Route path="workstations" element={<div>РС</div>} />
|
||||
<Route path="fiscals" element={<div>ФР</div>} />
|
||||
<Route path="admin" element={<div>Админка</div>} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
36
src/api/axios.ts
Normal file
36
src/api/axios.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request Interceptor: Добавляем JWT токен
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response Interceptor: Обработка 401 (истек токен)
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
// Опционально: можно добавить логику refresh token здесь
|
||||
useAuthStore.getState().logout();
|
||||
// Редирект на логин произойдет реактивно через App Router
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
173
src/components/layout/MainLayout.tsx
Normal file
173
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layout, Menu, Button, Dropdown, Avatar, theme as antTheme, Typography, Space } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
SearchOutlined,
|
||||
CheckSquareOutlined,
|
||||
CustomerServiceOutlined,
|
||||
BankOutlined,
|
||||
DesktopOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useUiStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
const { themeMode, toggleTheme } = useUiStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleMenuClick = (key: string) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'Профиль',
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: 'Выйти',
|
||||
icon: <LogoutOutlined />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <SearchOutlined />, label: 'Поиск' },
|
||||
{ key: '/tasks', icon: <CheckSquareOutlined />, label: 'Задачи' },
|
||||
{ key: '/tickets', icon: <CustomerServiceOutlined />, label: 'Тикеты' },
|
||||
{ key: '/companies', icon: <BankOutlined />, label: 'Компании' },
|
||||
{
|
||||
key: 'equipment',
|
||||
icon: <DesktopOutlined />,
|
||||
label: 'Оборудование',
|
||||
children: [
|
||||
{ key: '/servers', label: 'Серверы' },
|
||||
{ key: '/workstations', label: 'Рабочие станции' },
|
||||
{ key: '/fiscals', label: 'ФР' },
|
||||
],
|
||||
},
|
||||
{ key: '/admin', icon: <SettingOutlined />, label: 'Администрирование' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={250}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 64, margin: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Логотип заглушка */}
|
||||
<div style={{
|
||||
width: collapsed ? 32 : '100%',
|
||||
height: 32,
|
||||
background: token.colorPrimary,
|
||||
borderRadius: 6,
|
||||
opacity: 0.8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{collapsed ? 'ES' : 'Etalon ServiceDesk'}
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultSelectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
style={{ borderRight: 0, background: 'transparent' }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: token.colorBgContainer, // Прозрачность задана в теме
|
||||
backdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
<Text strong style={{ fontSize: 18, marginLeft: 16 }}>
|
||||
{/* Заголовок страницы можно сделать динамическим позже */}
|
||||
Dashboard
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space size="middle">
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={themeMode === 'light' ? <MoonOutlined /> : <SunOutlined />}
|
||||
onClick={toggleTheme}
|
||||
/>
|
||||
|
||||
<Dropdown menu={userMenu} placement="bottomRight" arrow>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar style={{ backgroundColor: token.colorPrimary }}>
|
||||
{user?.fullName?.[0] || 'A'}
|
||||
</Avatar>
|
||||
<Text>{user?.fullName || 'User'}</Text>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: '24px 16px',
|
||||
padding: 24,
|
||||
minHeight: 280,
|
||||
overflow: 'initial',
|
||||
// Контент будет рендериться здесь
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
36
src/index.css
Normal file
36
src/index.css
Normal file
@@ -0,0 +1,36 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Глобальный эффект стекла для компонентов AntD */
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Принудительно применяем эффект к карточкам и модалкам */
|
||||
.ant-card, .ant-modal-content, .ant-drawer-content {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Скроллбары для красоты */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #8c8c8c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
53
src/pages/Dashboard.tsx
Normal file
53
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Row, Col, Statistic } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={2}>Обзор системы</Typography.Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Активные тикеты"
|
||||
value={11.28}
|
||||
precision={2}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Новые задачи"
|
||||
value={9.3}
|
||||
precision={2}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
prefix={<ArrowDownOutlined />}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="Оборудование онлайн" value={93} suffix="/ 100" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24 }} title="Последние действия">
|
||||
<Typography.Paragraph>
|
||||
Здесь будет график или таблица с последними действиями в ServiceDesk.
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Система работает в штатном режиме.
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
82
src/pages/auth/LoginPage.tsx
Normal file
82
src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, message, Layout } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import apiClient from '@/api/axios';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Прямой запрос к API вместо отдельного сервиса для простоты на старте
|
||||
const response = await apiClient.post('/auth/login', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
const { access_token, user } = response.data.data;
|
||||
login(access_token, user);
|
||||
message.success('Вход выполнен успешно');
|
||||
navigate('/');
|
||||
} else {
|
||||
message.error('Ошибка формата ответа сервера');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
message.error(error.response?.data?.error?.error || 'Неверный логин или пароль');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Card
|
||||
style={{ width: 400, boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}
|
||||
className="glass-panel"
|
||||
bordered={false}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>Etalon ServiceDesk</Title>
|
||||
<Typography.Text type="secondary">Вход в систему</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login_form"
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onFinish}
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: 'Введите имя пользователя' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="Username" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: 'Введите пароль' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" style={{ width: '100%' }} loading={loading}>
|
||||
Войти
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
33
src/store/authStore.ts
Normal file
33
src/store/authStore.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Типы согласно API Reference
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
login: (token, user) => set({ token, user, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, user: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'etalon-auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
24
src/store/uiStore.ts
Normal file
24
src/store/uiStore.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
interface UiState {
|
||||
themeMode: ThemeMode;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
themeMode: 'light',
|
||||
toggleTheme: () =>
|
||||
set((state) => ({ themeMode: state.themeMode === 'light' ? 'dark' : 'light' })),
|
||||
setTheme: (mode) => set({ themeMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'etalon-ui-storage', // unique name for localStorage
|
||||
}
|
||||
)
|
||||
);
|
||||
49
src/theme/themeConfig.ts
Normal file
49
src/theme/themeConfig.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Исправленные импорты: разделяем значения и типы
|
||||
import { theme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
// Цветовые палитры для тем
|
||||
const lightColors = {
|
||||
primary: '#1890ff',
|
||||
bgContainer: 'rgba(230, 247, 255, 0.65)',
|
||||
bgLayout: '#f0f5ff',
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
primary: '#177ddc',
|
||||
bgContainer: 'rgba(17, 29, 44, 0.65)',
|
||||
bgLayout: '#0a111b',
|
||||
};
|
||||
|
||||
export const getThemeConfig = (mode: 'light' | 'dark'): ThemeConfig => {
|
||||
const isDark = mode === 'dark';
|
||||
const colors = isDark ? darkColors : lightColors;
|
||||
|
||||
return {
|
||||
// Теперь theme.darkAlgorithm и theme.defaultAlgorithm будут доступны
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: colors.primary,
|
||||
colorBgContainer: colors.bgContainer,
|
||||
colorBgLayout: colors.bgLayout,
|
||||
borderRadius: 8,
|
||||
wireframe: false,
|
||||
boxShadow: isDark
|
||||
? '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
: '0 4px 12px rgba(24, 144, 255, 0.15)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgHeader: isDark ? 'rgba(20, 20, 20, 0.6)' : 'rgba(255, 255, 255, 0.6)',
|
||||
colorBgTrigger: colors.primary,
|
||||
},
|
||||
Menu: {
|
||||
colorBgContainer: 'transparent',
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: colors.bgContainer,
|
||||
lineWidth: 1,
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user