13.12.25 - начало разработки фронта
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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?
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Stage 1: Build
|
||||
FROM node:24-alpine as builder
|
||||
WORKDIR /app
|
||||
|
||||
# Сначала копируем package файлы для кэширования слоев
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Копируем исходники и собираем
|
||||
COPY . .
|
||||
# Важно: TypeScript проверка может быть строгой, если будут ошибки сборки -
|
||||
# можно временно использовать "npm run build -- --emptyOutDir" или отключить tsc в package.json
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
# Копируем кастомный конфиг
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Копируем собранные файлы из предыдущего этапа
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||
<!-- Примечание: favicon можно заменить позже, пока оставим ссылку или уберем, если удалили файл -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Etalon ServiceDesk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
nginx.conf
Normal file
28
nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Сжатие (gzip) для скорости
|
||||
gzip on;
|
||||
gzip_types text/plain application/javascript text/css application/json;
|
||||
|
||||
# Корневая папка со сборкой
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 1. Раздача фронтенда (React Router support)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 2. Проксирование API на бэкенд-контейнер
|
||||
# http://server:8080 - это имя сервиса из docker-compose и его внутренний порт
|
||||
location /api/ {
|
||||
proxy_pass http://server:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Важно для Docker/Remote контейнеров: слушать все интерфейсы
|
||||
host: true,
|
||||
// Явно задаем порт
|
||||
port: 5173,
|
||||
// Если порт занят, Vite выйдет с ошибкой, а не будет искать следующий
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// Если бэкенд тоже запущен внутри этого контейнера на 9999:
|
||||
target: 'http://etalon-server:9999',
|
||||
// Если бэкенд на хост-машине, возможно понадобится http://host.docker.internal:9999
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user