From be9fdad7d0002566f7ec7cd4bb0ff01d09994fa8 Mon Sep 17 00:00:00 2001 From: SERTY Date: Sat, 13 Dec 2025 23:30:05 +0300 Subject: [PATCH] =?UTF-8?q?13.12.25=20-=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 24 ++++ Dockerfile | 23 ++++ README.md | 73 +++++++++++ eslint.config.js | 23 ++++ index.html | 14 +++ nginx.conf | 28 +++++ src/App.tsx | 78 ++++++++++++ src/api/axios.ts | 36 ++++++ src/components/layout/MainLayout.tsx | 173 +++++++++++++++++++++++++++ src/index.css | 36 ++++++ src/main.tsx | 10 ++ src/pages/Dashboard.tsx | 53 ++++++++ src/pages/auth/LoginPage.tsx | 82 +++++++++++++ src/store/authStore.ts | 33 +++++ src/store/uiStore.ts | 24 ++++ src/theme/themeConfig.ts | 49 ++++++++ vite.config.ts | 28 +++++ 17 files changed, 787 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 src/App.tsx create mode 100644 src/api/axios.ts create mode 100644 src/components/layout/MainLayout.tsx create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/auth/LoginPage.tsx create mode 100644 src/store/authStore.ts create mode 100644 src/store/uiStore.ts create mode 100644 src/theme/themeConfig.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8537258 --- /dev/null +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/README.md @@ -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... + }, + }, +]) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..f397f2d --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Etalon ServiceDesk + + +
+ + + \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..34a9c8d --- /dev/null +++ b/nginx.conf @@ -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; + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b5dd5c1 --- /dev/null +++ b/src/App.tsx @@ -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 ; + } + // 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 ( + + + + + } /> + + + + + }> + {/* Вложенные роуты внутри MainLayout */} + } /> + Задачи} /> + Тикеты} /> + Компании} /> + Серверы} /> + РС} /> + ФР} /> + Админка} /> + + + } /> + + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/src/api/axios.ts b/src/api/axios.ts new file mode 100644 index 0000000..2ae01ef --- /dev/null +++ b/src/api/axios.ts @@ -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; \ No newline at end of file diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..0fe456e --- /dev/null +++ b/src/components/layout/MainLayout.tsx @@ -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: , + }, + { + key: 'logout', + label: 'Выйти', + icon: , + onClick: handleLogout, + }, + ], + }; + + const menuItems = [ + { key: '/', icon: , label: 'Поиск' }, + { key: '/tasks', icon: , label: 'Задачи' }, + { key: '/tickets', icon: , label: 'Тикеты' }, + { key: '/companies', icon: , label: 'Компании' }, + { + key: 'equipment', + icon: , + label: 'Оборудование', + children: [ + { key: '/servers', label: 'Серверы' }, + { key: '/workstations', label: 'Рабочие станции' }, + { key: '/fiscals', label: 'ФР' }, + ], + }, + { key: '/admin', icon: , label: 'Администрирование' }, + ]; + + return ( + + +
+ {/* Логотип заглушка */} +
+ {collapsed ? 'ES' : 'Etalon ServiceDesk'} +
+
+ handleMenuClick(key)} + style={{ borderRight: 0, background: 'transparent' }} + /> + + +
+
+
+ + +
+ + + +
+ + ); +}; + +export default MainLayout; \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..53fd539 --- /dev/null +++ b/src/index.css @@ -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; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..24a16b6 --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +); \ No newline at end of file diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..934bbc6 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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 ( +
+ Обзор системы + + + + } + suffix="%" + /> + + + + + } + suffix="%" + /> + + + + + + + + + + + + Здесь будет график или таблица с последними действиями в ServiceDesk. + + + Система работает в штатном режиме. + + +
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..ee85835 --- /dev/null +++ b/src/pages/auth/LoginPage.tsx @@ -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 ( + + +
+ Etalon ServiceDesk + Вход в систему +
+ +
+ + } placeholder="Username" /> + + + } placeholder="Password" /> + + + + + +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..a6bb5cb --- /dev/null +++ b/src/store/authStore.ts @@ -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()( + 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', + } + ) +); \ No newline at end of file diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts new file mode 100644 index 0000000..3ceed81 --- /dev/null +++ b/src/store/uiStore.ts @@ -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()( + 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 + } + ) +); \ No newline at end of file diff --git a/src/theme/themeConfig.ts b/src/theme/themeConfig.ts new file mode 100644 index 0000000..e303a7a --- /dev/null +++ b/src/theme/themeConfig.ts @@ -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, + } + }, + }; +}; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2b2b3c7 --- /dev/null +++ b/vite.config.ts @@ -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, + }, + }, + }, +}); \ No newline at end of file