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