13.12.25 - начало разработки фронта

This commit is contained in:
2025-12-13 23:30:05 +03:00
commit be9fdad7d0
17 changed files with 787 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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,
},
},
},
});