Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb221c48e | ||
|
|
d98b4c1343 | ||
|
|
ac2bc13da0 |
@@ -5,5 +5,3 @@
|
||||
/admin-server/node_modules
|
||||
/backend/logs
|
||||
/admin-server1/node_modules
|
||||
/admin-frontend/node_modules
|
||||
/node_modules
|
||||
@@ -1,39 +0,0 @@
|
||||
# План оптимизации зависимостей
|
||||
|
||||
## Этап 1: Backend (наименее рискованный)
|
||||
- [x] 1.1 Обновить Express до 5.1.0
|
||||
- [x] 1.2 Заменить fs-extra на fs/promises в server.js, routes/employee.js, config/db.js
|
||||
- [x] 1.3 Удалить winston-daily-rotate-file (не используется)
|
||||
- [x] 1.4 Обновить dotenv до последней версии
|
||||
|
||||
## Этап 2: Чистка фронтенда от мусора
|
||||
- [x] 2.1 Удалить встроенные модули Node.js: fs, http, https, os, path, stream, util, zlib
|
||||
- [x] 2.2 Удалить mammoth и textract (не используются во frontend)
|
||||
- [x] 2.3 Удалить полифиллы Node.js: buffer, crypto-browserify, https-browserify, os-browserify, path-browserify, process, querystring-es3, stream-browserify, stream-http, url
|
||||
|
||||
## Этап 3: Обновление React 18.3 → 19
|
||||
- [x] 3.1 Обновить до React 18.3, исправить предупреждения
|
||||
- [x] 3.2 Проверить совместимость зависимостей
|
||||
- [x] 3.3 Обновить React до 19, react-dom до 19, @types/react и @types/react-dom до 19
|
||||
- [x] 3.4 Обновить @testing-library/* до актуальных версий
|
||||
|
||||
## Этап 4: Обновление остальных пакетов
|
||||
- [x] 4.1 Обновить react-pdf и pdfjs-dist до актуальных версий
|
||||
- [x] 4.2 Обновить Electron ^21 → ^37
|
||||
- [x] 4.3 Адаптировать PayslipViewer.tsx под новое API react-pdf
|
||||
|
||||
## Этап 5: Миграция на Vite (в последнюю очередь)
|
||||
- [x] 5.1 Создать vite.config.ts
|
||||
- [x] 5.2 Перенести index.html в корень, убрать CRA-теги
|
||||
- [x] 5.3 Обновить tsconfig.json
|
||||
- [x] 5.4 Обновить скрипты в package.json (vite, vitest)
|
||||
- [x] 5.5 Удалить react-scripts
|
||||
|
||||
## Этап 6: Проверка
|
||||
- [x] 6.1 npm install для backend и frontend
|
||||
- [x] 6.2 npm run build для frontend (Vite 8, 503ms)
|
||||
- [x] 6.3 Запуск backend
|
||||
|
||||
## Этап 7: Синхронизация версий Vite (после реструктуризации)
|
||||
- [x] 7.1 frontend обновлён до Vite 8.2.1 + plugin-react 6.0.5 (совпадение с admin-frontend)
|
||||
- [x] 7.2 Сборка frontend подтверждена (621 kB, gzip 187 kB, 503ms)
|
||||
@@ -1,24 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/node_modules
|
||||
@@ -1,70 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css" />
|
||||
<title>Admin Frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "admin-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"scripts": {
|
||||
"rebuild": "rm -rf dist && npm run build",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './App.css';
|
||||
import Login from './components/Login.jsx';
|
||||
import Index from './components/Index.jsx';
|
||||
import Editor from './components/Editor.jsx';
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState('loading');
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path === '/login' || path === '/login.html') {
|
||||
setCurrentPage('login');
|
||||
} else if (path === '/editor' || path === '/editor.html' || path === '/moders') {
|
||||
setCurrentPage('editor');
|
||||
} else {
|
||||
setCurrentPage('index');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'login':
|
||||
return <Login />;
|
||||
case 'editor':
|
||||
return <Editor />;
|
||||
case 'index':
|
||||
return <Index />;
|
||||
default:
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{renderPage()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import '../App.css';
|
||||
import DOMPurify from 'dompurify';
|
||||
import SlidesManager from './SlidesManager';
|
||||
import FoodManager from './FoodManager';
|
||||
|
||||
function Editor() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('info');
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingBlockId, setEditingBlockId] = useState(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editPreviewContent, setEditPreviewContent] = useState('');
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const quillRef = useRef(null);
|
||||
const originalContentRef = useRef('');
|
||||
|
||||
// Notify helper
|
||||
const showNotify = useCallback((message, type = 'success') => {
|
||||
setNotify({ message, type, show: true });
|
||||
setTimeout(() => {
|
||||
setNotify({ message: '', type: '', show: false });
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
// Check session on mount
|
||||
useEffect(() => {
|
||||
fetch('/moders/check-session', { credentials: 'include' })
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
setIsLoggedIn(true);
|
||||
loadBlocks(activeTab);
|
||||
} else {
|
||||
setIsLoggedIn(false);
|
||||
}
|
||||
})
|
||||
.catch(() => setIsLoggedIn(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
return fetch(url, options);
|
||||
}, []);
|
||||
|
||||
const loadBlocks = useCallback(async (tab) => {
|
||||
try {
|
||||
const res = await fetch(`/moders/content/${tab}`);
|
||||
const data = await res.json();
|
||||
setBlocks(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
setBlocks([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoginError('');
|
||||
try {
|
||||
const res = await fetch('/moders/moder-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
setIsLoggedIn(true);
|
||||
setLoginError('');
|
||||
loadBlocks(activeTab);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setLoginError(data.message || 'Ошибка входа');
|
||||
}
|
||||
} catch (err) {
|
||||
setLoginError('Ошибка сервера');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authenticatedFetch('/moders/moder-logout');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setIsLoggedIn(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
showNotify('Вы вышли из системы', 'success');
|
||||
};
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if (isLoggedIn && tab !== 'slides' && tab !== 'food') {
|
||||
loadBlocks(tab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBlock = () => {
|
||||
setEditingBlockId(null);
|
||||
setEditContent('<p>Новый блок</p>');
|
||||
setEditPreviewContent(DOMPurify.sanitize('<p>Новый блок</p>'));
|
||||
originalContentRef.current = '<p>Новый блок</p>';
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleEditBlock = (block) => {
|
||||
setEditingBlockId(block.id);
|
||||
setEditContent(block.content);
|
||||
setEditPreviewContent(DOMPurify.sanitize(block.content));
|
||||
originalContentRef.current = block.content;
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteBlock = async (blockId) => {
|
||||
if (!window.confirm('Удалить блок?')) return;
|
||||
try {
|
||||
await authenticatedFetch(`/moders/content/${activeTab}/${blockId}`, { method: 'DELETE' });
|
||||
showNotify('Блок удалён', 'success');
|
||||
loadBlocks(activeTab);
|
||||
} catch (e) {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBlock = async (e) => {
|
||||
e.preventDefault();
|
||||
const content = DOMPurify.sanitize(editContent.trim());
|
||||
if (!content || content === '<p><br></p>') return;
|
||||
|
||||
const url = editingBlockId
|
||||
? `/moders/content/${activeTab}/${editingBlockId}`
|
||||
: `/moders/content/${activeTab}`;
|
||||
|
||||
const method = editingBlockId ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
|
||||
setShowEditModal(false);
|
||||
loadBlocks(activeTab);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTryCloseEditModal = () => {
|
||||
if (editContent !== originalContentRef.current) {
|
||||
setShowConfirmModal(true);
|
||||
} else {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
setShowConfirmModal(false);
|
||||
setShowEditModal(false);
|
||||
};
|
||||
|
||||
const handleCancelClose = () => {
|
||||
setShowConfirmModal(false);
|
||||
};
|
||||
|
||||
// Initialize Quill when modal opens
|
||||
useEffect(() => {
|
||||
if (showEditModal && editorRef.current && !quillRef.current) {
|
||||
import('quill').then((QuillModule) => {
|
||||
const Quill = QuillModule.default || QuillModule;
|
||||
if (editorRef.current) {
|
||||
quillRef.current = new Quill(editorRef.current, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'align': [] }],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['blockquote', 'code-block'],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
quillRef.current.clipboard.dangerouslyPasteHTML(editContent);
|
||||
|
||||
quillRef.current.on('text-change', () => {
|
||||
const html = quillRef.current.root.innerHTML;
|
||||
setEditContent(html);
|
||||
setEditPreviewContent(DOMPurify.sanitize(html));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!showEditModal && quillRef.current) {
|
||||
quillRef.current = null;
|
||||
}
|
||||
}, [showEditModal, editContent]);
|
||||
|
||||
// Если не авторизован — показываем форму входа
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Панель модератора</h1>
|
||||
<div id="login-section">
|
||||
<h2>Вход для модератора</h2>
|
||||
<form id="moder-login-form" onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Логин:</label>
|
||||
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Авторизован — показываем редактор
|
||||
return (
|
||||
<>
|
||||
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
|
||||
<div className="container">
|
||||
<h1>Панель модератора</h1>
|
||||
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
|
||||
<h2>Редактирование вкладок</h2>
|
||||
|
||||
<div className="tab-controls">
|
||||
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
|
||||
Общая информация
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
|
||||
📅 Мероприятия
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
|
||||
💰 Материальная помощь
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
|
||||
🏠 Жилищные условия
|
||||
</button>
|
||||
{/* ✅ НОВАЯ ВКЛАДКА СЛАЙДЫ */}
|
||||
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
|
||||
📸 Слайды
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'food' ? 'active' : ''}`} onClick={() => setActiveTab('food')} data-tab="food">
|
||||
🍔 Питание
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Рендеринг контента вкладок */}
|
||||
{activeTab === 'slides' ? (
|
||||
<SlidesManager />
|
||||
) : activeTab === 'food' ? (
|
||||
<FoodManager />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ margin: '18px 0' }}>
|
||||
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
|
||||
</div>
|
||||
<div id="blocks-list">
|
||||
{blocks.length === 0 && <p>Нет блоков</p>}
|
||||
{blocks.map(block => (
|
||||
<div key={block.id} className="content-block">
|
||||
<div className="block-preview" style={{ marginBottom: '10px' }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(block.content) }} />
|
||||
<button onClick={() => handleEditBlock(block)}>Редактировать</button>
|
||||
<button onClick={() => handleDeleteBlock(block.id)}>Удалить</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования блоков */}
|
||||
{showEditModal && (
|
||||
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleTryCloseEditModal();
|
||||
}}>
|
||||
<div className="modal-content">
|
||||
<span className="close" onClick={handleTryCloseEditModal}>×</span>
|
||||
<h3 id="edit-modal-title">{editingBlockId ? 'Редактировать блок' : 'Добавить блок'}</h3>
|
||||
<form id="edit-block-form" onSubmit={handleSaveBlock}>
|
||||
<input type="hidden" id="edit-block-id" value={editingBlockId || ''} />
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-quill-editor">Холст:</label>
|
||||
<div id="edit-quill-editor" ref={editorRef} style={{ height: '250px' }}></div>
|
||||
</div>
|
||||
<div id="edit-preview-label" style={{ marginTop: '10px', fontWeight: 'bold' }}>Предпросмотр:</div>
|
||||
<div id="edit-block-preview" className="block-text" style={{ minHeight: '40px', marginBottom: '10px' }} dangerouslySetInnerHTML={{ __html: editPreviewContent }}></div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmModal && (
|
||||
<div id="confirm-modal" className="modal" style={{ display: 'block', zIndex: 2000 }}>
|
||||
<div className="modal-content" style={{ maxWidth: '400px', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '20px' }}>Вы уверены, что хотите закрыть окно?<br />Несохранённые изменения будут потеряны!</p>
|
||||
<button id="confirm-close-yes" style={{ background: '#e74c3c', marginRight: '12px' }} onClick={handleConfirmClose}>Да, закрыть</button>
|
||||
<button id="confirm-close-no" onClick={handleCancelClose}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
@@ -1,477 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
function FoodManager() {
|
||||
const [activeSubTab, setActiveSubTab] = useState('orders');
|
||||
|
||||
// --- ЗАКАЗЫ ---
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
const [orderStatus, setOrderStatus] = useState('all'); // all, new, accepted, done, cancelled
|
||||
|
||||
// --- МЕНЮ ---
|
||||
const [menuData, setMenuData] = useState({ company: '', categories: [] });
|
||||
const [loadingMenu, setLoadingMenu] = useState(false);
|
||||
const [showItemModal, setShowItemModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const [itemForm, setItemForm] = useState({
|
||||
category_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
weight_grams: '',
|
||||
image_url: ''
|
||||
});
|
||||
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
const showNotify = useCallback((message, type = 'success') => {
|
||||
setNotify({ message, type, show: true });
|
||||
setTimeout(() => setNotify({ message: '', type: '', show: false }), 3000);
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// ЗАКАЗЫ
|
||||
// ============================================
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoadingOrders(true);
|
||||
try {
|
||||
const url = orderStatus === 'all'
|
||||
? '/moders/food/orders'
|
||||
: `/moders/food/orders?status=${orderStatus}`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrders(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки заказов', 'error');
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [orderStatus, showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'orders') fetchOrders();
|
||||
}, [activeSubTab, fetchOrders]);
|
||||
|
||||
const updateOrderStatus = async (id, status) => {
|
||||
// Если это отмена — спрашиваем подтверждение
|
||||
if (status === 'cancelled') {
|
||||
if (!window.confirm('Вы уверены? Заказ будет отменён, деньги вернутся на баланс сотрудника. Это действие нельзя отменить.')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/moders/food/orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (data.refunded) {
|
||||
showNotify(`Заказ отменён. Возвращено: ${data.refund.toFixed(2)} BYN`, 'success');
|
||||
} else {
|
||||
showNotify('Статус обновлён', 'success');
|
||||
}
|
||||
fetchOrders();
|
||||
} else {
|
||||
showNotify(data.error || 'Ошибка обновления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка обновления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Экспорт в Excel
|
||||
const exportToExcel = () => {
|
||||
if (orders.length === 0) {
|
||||
showNotify('Нет заказов для экспорта', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем плоскую таблицу
|
||||
const rows = [];
|
||||
orders.forEach(order => {
|
||||
if (!order.items || order.items.length === 0) {
|
||||
rows.push({
|
||||
'№ Заказа': order.id,
|
||||
'Дата': new Date(order.createdAt).toLocaleString('ru-RU'),
|
||||
'Сотрудник': order.employee || 'Неизвестно',
|
||||
'Табельный': order.tnom,
|
||||
'Статус': order.status,
|
||||
'Блюдо': '(пусто)',
|
||||
'Кол-во': '',
|
||||
'Цена за шт.': '',
|
||||
'Сумма позиции': '',
|
||||
'Итого по заказу': order.totalAmount
|
||||
});
|
||||
} else {
|
||||
order.items.forEach((item, idx) => {
|
||||
rows.push({
|
||||
'№ Заказа': idx === 0 ? order.id : '', // Пишем ID только в первой строке
|
||||
'Дата': idx === 0 ? new Date(order.createdAt).toLocaleString('ru-RU') : '',
|
||||
'Сотрудник': idx === 0 ? (order.employee || 'Неизвестно') : '',
|
||||
'Табельный': idx === 0 ? order.tnom : '',
|
||||
'Статус': idx === 0 ? order.status : '',
|
||||
'Блюдо': item.name,
|
||||
'Кол-во': item.quantity,
|
||||
'Цена за шт.': item.unitPrice,
|
||||
'Сумма позиции': item.subtotal,
|
||||
'Итого по заказу': idx === 0 ? order.totalAmount : ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Заказы');
|
||||
XLSX.writeFile(wb, `Заказы_питания_${new Date().toLocaleDateString('ru-RU')}.xlsx`);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// МЕНЮ (БЛЮДА)
|
||||
// ============================================
|
||||
const fetchMenu = useCallback(async () => {
|
||||
setLoadingMenu(true);
|
||||
try {
|
||||
const res = await fetch('/moders/food/items', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMenuData({
|
||||
company: data.company,
|
||||
categories: data.categories || []
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки меню', 'error');
|
||||
} finally {
|
||||
setLoadingMenu(false);
|
||||
}
|
||||
}, [showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'menu') fetchMenu();
|
||||
}, [activeSubTab, fetchMenu]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setItemForm({
|
||||
category_id: menuData.categories[0]?.id || '',
|
||||
name: '', description: '', price: '', weight_grams: '', image_url: ''
|
||||
});
|
||||
setShowItemModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item, catId) => {
|
||||
setEditingItem({ ...item, category_id: catId });
|
||||
setItemForm({
|
||||
category_id: catId,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
price: item.price,
|
||||
weight_grams: item.weightGrams || '',
|
||||
image_url: item.imageUrl || ''
|
||||
});
|
||||
setShowItemModal(true);
|
||||
};
|
||||
|
||||
const handleSaveItem = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
formData.append('category_id', itemForm.category_id);
|
||||
formData.append('name', itemForm.name);
|
||||
formData.append('description', itemForm.description);
|
||||
formData.append('price', itemForm.price);
|
||||
formData.append('weight_grams', itemForm.weight_grams);
|
||||
|
||||
if (itemForm.imageFile) {
|
||||
formData.append('image', itemForm.imageFile);
|
||||
} else if (itemForm.image_url) {
|
||||
formData.append('current_image_url', itemForm.image_url);
|
||||
}
|
||||
|
||||
const url = editingItem
|
||||
? `/moders/food/items/${editingItem.id}`
|
||||
: '/moders/food/items';
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify(editingItem ? 'Блюдо обновлено' : 'Блюдо добавлено', 'success');
|
||||
setShowItemModal(false);
|
||||
fetchMenu();
|
||||
} else {
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotify('Ошибка сервера', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id) => {
|
||||
if (!window.confirm('Удалить это блюдо?')) return;
|
||||
try {
|
||||
const res = await fetch(`/moders/food/items/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify('Блюдо удалено', 'success');
|
||||
fetchMenu();
|
||||
} else {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// РЕНДЕР
|
||||
// ============================================
|
||||
return (
|
||||
<div style={{ paddingBottom: '40px' }}>
|
||||
{/* Уведомления */}
|
||||
{notify.show && (
|
||||
<div style={{
|
||||
padding: '12px 16px', marginBottom: '15px', borderRadius: '8px',
|
||||
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
|
||||
color: notify.type === 'success' ? '#155724' : '#721c24',
|
||||
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
|
||||
}}>
|
||||
{notify.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Суб-вкладки */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', borderBottom: '2px solid #eee', paddingBottom: '10px' }}>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('orders')}
|
||||
style={{
|
||||
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||
background: activeSubTab === 'orders' ? '#0056b3' : '#f8f9fa',
|
||||
color: activeSubTab === 'orders' ? 'white' : '#333',
|
||||
fontWeight: activeSubTab === 'orders' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
📦 Заказы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('menu')}
|
||||
style={{
|
||||
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||
background: activeSubTab === 'menu' ? '#0056b3' : '#f8f9fa',
|
||||
color: activeSubTab === 'menu' ? 'white' : '#333',
|
||||
fontWeight: activeSubTab === 'menu' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
🍔 Управление меню
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ================= ЗАКАЗЫ ================= */}
|
||||
{activeSubTab === 'orders' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<select
|
||||
value={orderStatus}
|
||||
onChange={(e) => setOrderStatus(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="new">Новые</option>
|
||||
<option value="accepted">Принятые</option>
|
||||
<option value="done">Выполненные</option>
|
||||
<option value="cancelled">Отменённые</option>
|
||||
</select>
|
||||
<button onClick={fetchOrders} className="btn-small">Обновить</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
style={{ padding: '8px 16px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||
>
|
||||
📥 Экспорт в Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingOrders ? (
|
||||
<p>Загрузка заказов...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p>Заказов нет.</p>
|
||||
) : (
|
||||
<table className="moders-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Дата</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Табельный</th>
|
||||
<th>Состав</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map(o => (
|
||||
<tr key={o.id}>
|
||||
<td>{o.id}</td>
|
||||
<td>{new Date(o.createdAt).toLocaleString('ru-RU')}</td>
|
||||
<td>{o.employee}</td>
|
||||
<td>{o.tnom}</td>
|
||||
<td>
|
||||
<ul style={{ margin: 0, paddingLeft: '15px', fontSize: '12px' }}>
|
||||
{o.items.map((it, i) => (
|
||||
<li key={i}>{it.name} x{it.quantity}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td>{o.totalAmount.toFixed(2)}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold',
|
||||
background: o.status === 'new' ? '#ffc107' : o.status === 'done' ? '#28a745' : '#17a2b8',
|
||||
color: 'white'
|
||||
}}>
|
||||
{o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{o.status === 'cancelled' ? (
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
background: '#fdecea',
|
||||
color: '#c0392b',
|
||||
border: '1px solid #f5c6cb'
|
||||
}}>
|
||||
Отменён
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={o.status}
|
||||
onChange={(e) => updateOrderStatus(o.id, e.target.value)}
|
||||
style={{ padding: '6px', borderRadius: '4px', border: '1px solid #ccc', fontSize: '13px' }}
|
||||
>
|
||||
<option value="new">Новый</option>
|
||||
<option value="accepted">Принят</option>
|
||||
<option value="done">Выполнен</option>
|
||||
<option value="cancelled">Отменить (вернуть деньги)</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================= МЕНЮ ================= */}
|
||||
{activeSubTab === 'menu' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0 }}>Блюда в меню</h3>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
style={{ padding: '8px 16px', background: '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||
>
|
||||
+ Добавить блюдо
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingMenu ? <p>Загрузка меню...</p> : menuData.categories.map(cat => (
|
||||
<div key={cat.id} style={{ marginBottom: '30px' }}>
|
||||
<h4 style={{ borderBottom: '1px solid #ccc', paddingBottom: '5px' }}>{cat.emoji} {cat.name}</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '15px' }}>
|
||||
{cat.items.map(item => (
|
||||
<div key={item.id} style={{ background: '#f9f9f9', borderRadius: '8px', padding: '10px', border: '1px solid #ddd', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ height: '80px', background: '#eee', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px' }}>
|
||||
{item.imageUrl ? <img src={`http://localhost:3001${item.imageUrl}`} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '4px' }} /> : '🍽️'}
|
||||
</div>
|
||||
<div style={{ fontWeight: 'bold', marginTop: '8px' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', flex: 1 }}>{item.description}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' }}>
|
||||
<strong style={{ color: '#007bff' }}>{item.price} BYN</strong>
|
||||
<div>
|
||||
<button onClick={() => openEditModal(item, cat.id)} className="btn-small" style={{ marginRight: '5px' }}>✏️</button>
|
||||
<button onClick={() => handleDeleteItem(item.id)} className="btn-small" style={{ background: '#dc3545', color: 'white' }}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* МОДАЛКА РЕДАКТИРОВАНИЯ БЛЮДА */}
|
||||
{showItemModal && (
|
||||
<div className="modal" style={{ display: 'block', position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 2000, padding: '20px', overflowY: 'auto' }}>
|
||||
<div className="modal-content" style={{ maxWidth: '500px', margin: '40px auto', background: '#fff', borderRadius: '12px', padding: '20px' }}>
|
||||
<h3>{editingItem ? 'Редактировать блюдо' : 'Добавить блюдо'}</h3>
|
||||
<form onSubmit={handleSaveItem}>
|
||||
<div className="form-group">
|
||||
<label>Категория:</label>
|
||||
<select
|
||||
value={itemForm.category_id}
|
||||
onChange={(e) => setItemForm({...itemForm, category_id: e.target.value})}
|
||||
required
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
>
|
||||
{menuData.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Название:</label>
|
||||
<input type="text" value={itemForm.name} onChange={(e) => setItemForm({...itemForm, name: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Описание:</label>
|
||||
<input type="text" value={itemForm.description} onChange={(e) => setItemForm({...itemForm, description: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Цена (BYN):</label>
|
||||
<input type="number" step="0.01" value={itemForm.price} onChange={(e) => setItemForm({...itemForm, price: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Вес (гр):</label>
|
||||
<input type="number" value={itemForm.weight_grams} onChange={(e) => setItemForm({...itemForm, weight_grams: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Картинка (оставьте пустым, чтобы не менять):</label>
|
||||
<input type="file" onChange={(e) => setItemForm({...itemForm, imageFile: e.target.files[0]})} accept="image/*" style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
||||
<button type="submit" style={{ padding: '10px 20px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Сохранить</button>
|
||||
<button type="button" onClick={() => setShowItemModal(false)} style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FoodManager;
|
||||
@@ -1,789 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import '../App.css';
|
||||
|
||||
// CSRF token management
|
||||
//let csrfToken = null;
|
||||
//async function fetchCsrfToken() {
|
||||
// try {
|
||||
// const res = await fetch('/moders/csrf-token', { credentials: 'include' });
|
||||
// if (res.ok) {
|
||||
// const data = await res.json();
|
||||
// csrfToken = data.csrfToken;
|
||||
//}
|
||||
//} catch (e) { csrfToken = null; }
|
||||
//}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
if (!options.headers) options.headers = {};
|
||||
console.log(`📤 [${options.method || 'GET'}] ${url}`, options.body || '');
|
||||
options.credentials = 'include';
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
function Index() {
|
||||
const [activeTab, setActiveTab] = useState('main');
|
||||
const [serverStatus, setServerStatus] = useState('загрузка...');
|
||||
const [serverStatusColor, setServerStatusColor] = useState('#333');
|
||||
const [adminDbStatus, setAdminDbStatus] = useState('неизвестно');
|
||||
const [adminDbMessage, setAdminDbMessage] = useState('');
|
||||
const [adminDbColor, setAdminDbColor] = useState('#333');
|
||||
const [backendDbStatus, setBackendDbStatus] = useState('неизвестно');
|
||||
const [backendDbMessage, setBackendDbMessage] = useState('');
|
||||
const [backendDbColor, setBackendDbColor] = useState('#333');
|
||||
const [pingIp, setPingIp] = useState('');
|
||||
const [pingOutput, setPingOutput] = useState('');
|
||||
const [isPinging, setIsPinging] = useState(false);
|
||||
const [appLogs, setAppLogs] = useState('Нажмите "Обновить логи" для просмотра');
|
||||
const [cardLogs, setCardLogs] = useState('Нажмите "Обновить логи считывателя" для просмотра');
|
||||
const [payslipsPath, setPayslipsPath] = useState('');
|
||||
const [configStatus, setConfigStatus] = useState('');
|
||||
const [configStatusColor, setConfigStatusColor] = useState('green');
|
||||
|
||||
// Server Params
|
||||
const [adminPort, setAdminPort] = useState('');
|
||||
const [adminUrl, setAdminUrl] = useState('');
|
||||
const [backendHost, setBackendHost] = useState('');
|
||||
const [backendPort, setBackendPort] = useState('');
|
||||
const [serverParamsStatus, setServerParamsStatus] = useState('');
|
||||
|
||||
// MySQL Params
|
||||
const [mysqlHost, setMysqlHost] = useState('');
|
||||
const [mysqlPort, setMysqlPort] = useState('');
|
||||
const [mysqlUser, setMysqlUser] = useState('');
|
||||
const [mysqlPass, setMysqlPass] = useState('');
|
||||
const [mysqlPassReadOnly, setMysqlPassReadOnly] = useState(true);
|
||||
const [mysqlDb, setMysqlDb] = useState('');
|
||||
const [mysqlStatus, setMysqlStatus] = useState('');
|
||||
|
||||
// CORS
|
||||
const [corsOrigins, setCorsOrigins] = useState([]);
|
||||
const [newCorsOrigin, setNewCorsOrigin] = useState('');
|
||||
const [corsStatus, setCorsStatus] = useState('');
|
||||
|
||||
// Moders
|
||||
const [moderUsername, setModerUsername] = useState('');
|
||||
const [moderPassword, setModerPassword] = useState('');
|
||||
const [addModerStatus, setAddModerStatus] = useState('');
|
||||
const [moders, setModers] = useState([]);
|
||||
|
||||
// Monitoring
|
||||
const [logLevelFilter, setLogLevelFilter] = useState('all');
|
||||
const [logSourceFilter, setLogSourceFilter] = useState('all');
|
||||
const [monitoringLogs, setMonitoringLogs] = useState([]);
|
||||
const [monitoringInitialized, setMonitoringInitialized] = useState(false);
|
||||
const [settingsInitialized, setSettingsInitialized] = useState(false);
|
||||
|
||||
// Toast
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const showToast = useCallback((message, type = 'success') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const fetchServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
if (data.status === 'online') {
|
||||
setServerStatus('Онлайн');
|
||||
setServerStatusColor('green');
|
||||
} else {
|
||||
setServerStatus('Оффлайн');
|
||||
setServerStatusColor('red');
|
||||
}
|
||||
} catch (err) {
|
||||
setServerStatus('Ошибка');
|
||||
setServerStatusColor('orange');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDbStatus = useCallback(async () => {
|
||||
setAdminDbStatus('проверка...');
|
||||
setBackendDbStatus('проверка...');
|
||||
setAdminDbMessage('');
|
||||
setBackendDbMessage('');
|
||||
setAdminDbColor('#333');
|
||||
setBackendDbColor('#333');
|
||||
try {
|
||||
const response = await fetch('/api/health/check-db');
|
||||
const data = await response.json();
|
||||
const renderStatus = (status, message) => {
|
||||
return {
|
||||
text: status === 'online' ? 'Онлайн' : 'Оффлайн',
|
||||
color: status === 'online' ? 'green' : 'red',
|
||||
message: message || ''
|
||||
};
|
||||
};
|
||||
const admin = renderStatus(data.adminToDb.status, data.adminToDb.message);
|
||||
setAdminDbStatus(admin.text);
|
||||
setAdminDbColor(admin.color);
|
||||
setAdminDbMessage(admin.message);
|
||||
const backend = renderStatus(data.backendToDb.status, data.backendToDb.message);
|
||||
setBackendDbStatus(backend.text);
|
||||
setBackendDbColor(backend.color);
|
||||
setBackendDbMessage(backend.message);
|
||||
} catch (err) {
|
||||
setAdminDbStatus('Ошибка');
|
||||
setAdminDbColor('orange');
|
||||
setBackendDbStatus('Ошибка');
|
||||
setBackendDbColor('orange');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const config = await response.json();
|
||||
setPayslipsPath(config.payslipsPath || '');
|
||||
} catch (err) {
|
||||
setConfigStatus('Не удалось загрузить конфигурацию.');
|
||||
setConfigStatusColor('red');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setAppLogs('загрузка...');
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи');
|
||||
const data = await response.text();
|
||||
setAppLogs(data || 'Лог-файл пуст.');
|
||||
} catch (err) {
|
||||
setAppLogs(`Ошибка при загрузке логов: ${err.message}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCardLogs = useCallback(async () => {
|
||||
setCardLogs('загрузка...');
|
||||
try {
|
||||
const response = await fetch('/api/card/card-logs');
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи считывателя');
|
||||
const data = await response.text();
|
||||
setCardLogs(data || 'Лог-файл пуст.');
|
||||
} catch (err) {
|
||||
setCardLogs(`Ошибка при загрузке логов: ${err.message}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearLogs = useCallback(async () => {
|
||||
if (!window.confirm('Это действие удалит лог-файл полностью и навсегда. Вы уверены?')) return;
|
||||
try {
|
||||
const response = await fetch('/api/logs/clear', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
fetchLogs();
|
||||
} else {
|
||||
alert('Не удалось очистить логи.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Произошла ошибка при очистке логов.');
|
||||
}
|
||||
}, [fetchLogs]);
|
||||
|
||||
const handlePing = useCallback(() => {
|
||||
const ip = pingIp.trim();
|
||||
if (!ip) {
|
||||
setPingOutput('Пожалуйста, введите IP-адрес.');
|
||||
return;
|
||||
}
|
||||
setIsPinging(true);
|
||||
setPingOutput('');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${wsProtocol}://${window.location.host}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'ping', ip }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
setPingOutput(prev => prev + message.data);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setPingOutput(prev => prev + '\nОшибка WebSocket соединения.');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsPinging(false);
|
||||
};
|
||||
}, [pingIp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/browse');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPayslipsPath(data.path);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Не удалось открыть диалог выбора папки.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfigSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await apiFetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ payslipsPath })
|
||||
});
|
||||
const data = await response.json();
|
||||
setConfigStatus('Конфигурация успешно сохранена!');
|
||||
setConfigStatusColor('green');
|
||||
showToast('Конфигурация сохранена', 'success');
|
||||
setTimeout(() => setConfigStatus(''), 3000);
|
||||
} catch (err) {
|
||||
setConfigStatus('Ошибка при сохранении.');
|
||||
setConfigStatusColor('red');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
}, [payslipsPath, showToast]);
|
||||
|
||||
// Settings tab initialization
|
||||
const initSettingsTab = useCallback(async () => {
|
||||
if (settingsInitialized) return;
|
||||
setSettingsInitialized(true);
|
||||
|
||||
// Load server params
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
apiFetch('/api/settings/admin-server'),
|
||||
apiFetch('/api/settings/backend-server')
|
||||
]);
|
||||
if (results[0].status === 'fulfilled') {
|
||||
const adminData = await results[0].value.json();
|
||||
setAdminPort(adminData.PORT || '');
|
||||
setAdminUrl(adminData.ADMIN_URL || '');
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
const backendData = await results[1].value.json();
|
||||
setBackendHost(backendData.host || '');
|
||||
setBackendPort(backendData.port || '');
|
||||
}
|
||||
} catch (e) {
|
||||
setServerParamsStatus('Критическая ошибка загрузки параметров');
|
||||
}
|
||||
|
||||
// Load MySQL params
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql');
|
||||
const data = await res.json();
|
||||
setMysqlHost(data.MYSQL_HOST || '');
|
||||
setMysqlPort(data.MYSQL_PORT || '');
|
||||
setMysqlUser(data.MYSQL_USER || '');
|
||||
setMysqlPass(data.MYSQL_PASSWORD ? '********' : '');
|
||||
setMysqlPassReadOnly(true);
|
||||
setMysqlDb(data.MYSQL_DATABASE || '');
|
||||
} catch (e) {
|
||||
setMysqlStatus('Ошибка загрузки');
|
||||
}
|
||||
|
||||
// Load CORS
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors');
|
||||
const data = await res.json();
|
||||
setCorsOrigins(Array.isArray(data.ALLOWED_ORIGINS) ? data.ALLOWED_ORIGINS : []);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Load Moders
|
||||
try {
|
||||
const res = await apiFetch('/api/moders');
|
||||
const modersData = await res.json();
|
||||
setModers(Array.isArray(modersData) ? modersData : []);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, [settingsInitialized]);
|
||||
|
||||
// Monitoring tab initialization
|
||||
const initMonitoringTab = useCallback(async () => {
|
||||
if (monitoringInitialized) return;
|
||||
setMonitoringInitialized(true);
|
||||
fetchMonitoringLogs();
|
||||
}, [monitoringInitialized]);
|
||||
|
||||
const fetchMonitoringLogs = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/monitoring/logs');
|
||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
||||
const data = await res.json();
|
||||
setMonitoringLogs(data);
|
||||
} catch (e) {
|
||||
console.error('Error loading monitoring logs:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerStatus();
|
||||
fetchDbStatus();
|
||||
fetchConfig();
|
||||
}, [fetchServerStatus, fetchDbStatus, fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'settings') {
|
||||
initSettingsTab();
|
||||
} else if (activeTab === 'monitoring') {
|
||||
initMonitoringTab();
|
||||
}
|
||||
}, [activeTab, initSettingsTab, initMonitoringTab]);
|
||||
|
||||
const handleServerParamsSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
setServerParamsStatus('');
|
||||
try {
|
||||
const [adminRes, backendRes] = await Promise.all([
|
||||
apiFetch('/api/settings/admin-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ PORT: adminPort, ADMIN_URL: adminUrl })
|
||||
}),
|
||||
apiFetch('/api/settings/backend-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: backendHost, port: backendPort })
|
||||
})
|
||||
]);
|
||||
if (adminRes.ok && backendRes.ok) {
|
||||
setServerParamsStatus('Параметры успешно сохранены. Требуется перезапуск обоих серверов для применения!');
|
||||
showToast('Параметры успешно сохранены', 'success');
|
||||
} else {
|
||||
setServerParamsStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setServerParamsStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
}, [adminPort, adminUrl, backendHost, backendPort, showToast]);
|
||||
|
||||
const handleMysqlSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
MYSQL_HOST: mysqlHost,
|
||||
MYSQL_PORT: mysqlPort,
|
||||
MYSQL_USER: mysqlUser,
|
||||
MYSQL_DATABASE: mysqlDb
|
||||
};
|
||||
if (!mysqlPassReadOnly) {
|
||||
payload.MYSQL_PASSWORD = mysqlPass;
|
||||
}
|
||||
setMysqlStatus('');
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
setMysqlStatus('Сохранено. Требуется перезапуск Admin-server для применения!');
|
||||
showToast('Сохранено', 'success');
|
||||
// Reload MySQL params
|
||||
const reloadRes = await apiFetch('/api/settings/mysql');
|
||||
const data = await reloadRes.json();
|
||||
setMysqlHost(data.MYSQL_HOST || '');
|
||||
setMysqlPort(data.MYSQL_PORT || '');
|
||||
setMysqlUser(data.MYSQL_USER || '');
|
||||
setMysqlPass(data.MYSQL_PASSWORD ? '********' : '');
|
||||
setMysqlPassReadOnly(true);
|
||||
setMysqlDb(data.MYSQL_DATABASE || '');
|
||||
} else {
|
||||
setMysqlStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setMysqlStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
}, [mysqlHost, mysqlPort, mysqlUser, mysqlPass, mysqlPassReadOnly, mysqlDb, showToast]);
|
||||
|
||||
const handleCorsAdd = useCallback(() => {
|
||||
if (newCorsOrigin && !corsOrigins.includes(newCorsOrigin)) {
|
||||
setCorsOrigins([...corsOrigins, newCorsOrigin]);
|
||||
setNewCorsOrigin('');
|
||||
}
|
||||
}, [newCorsOrigin, corsOrigins]);
|
||||
|
||||
const handleCorsRemove = useCallback((originToRemove) => {
|
||||
setCorsOrigins(corsOrigins.filter(origin => origin !== originToRemove));
|
||||
}, []);
|
||||
|
||||
const handleCorsSubmit = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
setCorsStatus('');
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ALLOWED_ORIGINS: corsOrigins })
|
||||
});
|
||||
if (res.ok) {
|
||||
setCorsStatus('Сохранено. Требуется перезапуск Admin-server для применения!');
|
||||
showToast('Сохранено', 'success');
|
||||
} else {
|
||||
setCorsStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setCorsStatus('Ошибка сохранения');
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
}, [corsOrigins, showToast]);
|
||||
|
||||
const handleAddModer = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
console.log('вызыван писюн')
|
||||
setAddModerStatus('');
|
||||
if (!moderUsername || !moderPassword) {
|
||||
setAddModerStatus('Заполните все поля');
|
||||
return;
|
||||
}
|
||||
const payload = { username: moderUsername, password: moderPassword };
|
||||
console.log('📤 Отправка модератора:', payload);
|
||||
try {
|
||||
const res = await apiFetch('/api/moders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: moderUsername, password: moderPassword })
|
||||
});
|
||||
if (res.ok) {
|
||||
setAddModerStatus('Модератор добавлен');
|
||||
setModerUsername('');
|
||||
setModerPassword('');
|
||||
showToast('Модератор добавлен', 'success');
|
||||
// Reload moders
|
||||
const modersRes = await apiFetch('/api/moders');
|
||||
const modersData = await modersRes.json();
|
||||
setModers(Array.isArray(modersData) ? modersData : []);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setAddModerStatus(data.message || 'Ошибка');
|
||||
showToast(data.message || 'Ошибка', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setAddModerStatus('Ошибка');
|
||||
showToast('Ошибка', 'error');
|
||||
}
|
||||
}, [moderUsername, moderPassword, showToast]);
|
||||
|
||||
const handleDeleteModer = useCallback(async (id) => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setModers(moders.filter(moder => moder.id !== id));
|
||||
showToast('Модератор удален', 'success');
|
||||
} else {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
}, [moders, showToast]);
|
||||
|
||||
const handleResetModerPass = useCallback(async (id, newPass) => {
|
||||
if (!newPass || newPass.length < 4) {
|
||||
showToast('Пароль должен быть не менее 4 символов', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: newPass })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Пароль обновлён', 'success');
|
||||
return true;
|
||||
} else {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
return false;
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const getLogLevelClass = (level) => {
|
||||
if (level === 'ERROR') return 'log-level-error';
|
||||
if (level === 'WARN') return 'log-level-warn';
|
||||
return '';
|
||||
};
|
||||
|
||||
const filteredMonitoringLogs = monitoringLogs
|
||||
.filter(log => (logLevelFilter === 'all' || log.level === logLevelFilter))
|
||||
.filter(log => (logSourceFilter === 'all' || log.source === logSourceFilter));
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Toast container */}
|
||||
<div id="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1>Панель администратора</h1>
|
||||
<div className="tabs">
|
||||
<button id="tab-main" className={`tab-btn ${activeTab === 'main' ? 'active' : ''}`} onClick={() => setActiveTab('main')}>Общее</button>
|
||||
<button id="tab-settings" className={`tab-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>Настройки</button>
|
||||
<button id="tab-monitoring" className={`tab-btn ${activeTab === 'monitoring' ? 'active' : ''}`} onClick={() => setActiveTab('monitoring')}>Мониторинг</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'main' && (
|
||||
<div id="main-tab">
|
||||
<div id="status">
|
||||
<p>Статус сервера: <span id="server-status" style={{ color: serverStatusColor }}>{serverStatus}</span></p>
|
||||
<button id="refresh-status-btn" className="btn-small" onClick={fetchServerStatus}>Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="db-status-container">
|
||||
<div>
|
||||
<p>Админ БД: <span id="admin-db-status" style={{ color: adminDbColor }}>{adminDbStatus}</span></p>
|
||||
<small id="admin-db-message">{adminDbMessage}</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>Программа БД: <span id="backend-db-status" style={{ color: backendDbColor }}>{backendDbStatus}</span></p>
|
||||
<small id="backend-db-message">{backendDbMessage}</small>
|
||||
</div>
|
||||
<button id="check-db-btn" className="btn-small" onClick={fetchDbStatus}>Проверить</button>
|
||||
</div>
|
||||
|
||||
<div className="network-diag-container">
|
||||
<h2>Сетевая диагностика (Ping)</h2>
|
||||
<div className="ping-controls">
|
||||
<input type="text" id="ping-ip" placeholder="Введите IP-адрес или хост" value={pingIp} onChange={(e) => setPingIp(e.target.value)} />
|
||||
<button id="ping-btn" className="btn-small" onClick={handlePing} disabled={isPinging}>Пинг</button>
|
||||
<button id="ping-clear-btn" className="btn-small" onClick={() => setPingOutput('')}>Очистить</button>
|
||||
</div>
|
||||
<pre id="ping-output">{pingOutput}</pre>
|
||||
</div>
|
||||
|
||||
<div className="logs-container">
|
||||
<h2>Логи приложения</h2>
|
||||
<div className="log-controls">
|
||||
<button id="refresh-logs-btn" className="btn-small" onClick={fetchLogs}>Обновить логи</button>
|
||||
<button id="clear-logs-btn" className="btn-small" onClick={clearLogs}>Очистить</button>
|
||||
</div>
|
||||
<pre id="logs-content">{appLogs}</pre>
|
||||
</div>
|
||||
|
||||
<div className="logs-container">
|
||||
<h2>Логи считывателя</h2>
|
||||
<div className="log-controls">
|
||||
<button id="refresh-card-logs-btn" className="btn-small" onClick={fetchCardLogs}>Обновить логи считывателя</button>
|
||||
</div>
|
||||
<pre id="card-logs-content">{cardLogs}</pre>
|
||||
</div>
|
||||
|
||||
<div className="config-container">
|
||||
<h2>Конфигурация</h2>
|
||||
<form id="config-form" onSubmit={handleConfigSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="payslipsPath">Путь к расчетным листкам:</label>
|
||||
<div className="input-with-button">
|
||||
<input type="text" id="payslipsPath" name="payslipsPath" required value={payslipsPath} onChange={(e) => setPayslipsPath(e.target.value)} />
|
||||
<button type="button" id="browse-btn" onClick={handleBrowse}>Обзор...</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p id="config-status" style={{ color: configStatusColor }}>{configStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div id="settings-tab">
|
||||
<div id="server-params-section">
|
||||
<h3>Параметры серверов</h3>
|
||||
<form id="server-params-form" onSubmit={handleServerParamsSubmit} noValidate>
|
||||
<fieldset>
|
||||
<legend>Admin-server</legend>
|
||||
<label>PORT: <input type="number" id="admin-port" min="1" max="65535" required value={adminPort} onChange={(e) => setAdminPort(e.target.value)} /></label><br />
|
||||
<label>ADMIN_URL: <input type="text" id="admin-url" pattern="https?://.+" required value={adminUrl} onChange={(e) => setAdminUrl(e.target.value)} /></label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Backend</legend>
|
||||
<label>host: <input type="text" id="backend-host" required value={backendHost} onChange={(e) => setBackendHost(e.target.value)} /></label><br />
|
||||
<label>port: <input type="number" id="backend-port" min="1" max="65535" required value={backendPort} onChange={(e) => setBackendPort(e.target.value)} /></label>
|
||||
</fieldset>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="server-params-status" className="form-error">{serverParamsStatus}</span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="db-section">
|
||||
<h3>Подключения к БД</h3>
|
||||
<form id="mysql-form" onSubmit={handleMysqlSubmit} noValidate>
|
||||
<fieldset>
|
||||
<legend>MySQL (модераторы)</legend>
|
||||
<label>MYSQL_HOST: <input type="text" id="mysql-host" required value={mysqlHost} onChange={(e) => setMysqlHost(e.target.value)} /></label><br />
|
||||
<label>MYSQL_PORT: <input type="number" id="mysql-port" min="1" max="65535" required value={mysqlPort} onChange={(e) => setMysqlPort(e.target.value)} /></label><br />
|
||||
<label>MYSQL_USER: <input type="text" id="mysql-user" required value={mysqlUser} onChange={(e) => setMysqlUser(e.target.value)} /></label><br />
|
||||
<label>MYSQL_PASSWORD:
|
||||
<div className="password-wrapper">
|
||||
<input type="password" id="mysql-pass" readOnly={mysqlPassReadOnly} value={mysqlPassReadOnly ? '********' : mysqlPass} onChange={(e) => setMysqlPass(e.target.value)} />
|
||||
{mysqlPassReadOnly && (
|
||||
<button type="button" className="btn-small" onClick={() => { setMysqlPassReadOnly(false); setMysqlPass(''); }}>Изменить</button>
|
||||
)}
|
||||
</div>
|
||||
</label><br />
|
||||
<label>MYSQL_DATABASE: <input type="text" id="mysql-db" required value={mysqlDb} onChange={(e) => setMysqlDb(e.target.value)} /></label><br />
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="mysql-status" className="form-error">{mysqlStatus}</span>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<div id="cors-section">
|
||||
<h3>CORS (ALLOWED_ORIGINS)</h3>
|
||||
<form id="cors-form" onSubmit={handleCorsSubmit}>
|
||||
<ul id="cors-list">
|
||||
{corsOrigins.length === 0 && <li>Нет origin</li>}
|
||||
{corsOrigins.map((origin, index) => (
|
||||
<li key={index}>
|
||||
{origin}{' '}
|
||||
<button type="button" className="btn-small" onClick={() => handleCorsRemove(origin)}>Удалить</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input type="text" id="cors-new-origin" placeholder="Добавить origin..." value={newCorsOrigin} onChange={(e) => setNewCorsOrigin(e.target.value)} />
|
||||
<button type="button" id="cors-add-btn" onClick={handleCorsAdd} disabled={!newCorsOrigin || corsOrigins.includes(newCorsOrigin)}>Добавить</button>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="cors-status" className="form-error">{corsStatus}</span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="moders-section">
|
||||
<h3>Модераторы</h3>
|
||||
<form id="add-moder-form" onSubmit={handleAddModer} noValidate>
|
||||
<input type="text" id="add-moder-username" placeholder="Логин" required value={moderUsername} onChange={(e) => setModerUsername(e.target.value)} />
|
||||
<input type="password" id="add-moder-password" placeholder="Пароль (мин. 4 символа)" minLength="4" required value={moderPassword} onChange={(e) => setModerPassword(e.target.value)} />
|
||||
<button type="submit">Добавить</button>
|
||||
<span id="add-moder-status" className="form-error">{addModerStatus}</span>
|
||||
</form>
|
||||
<table id="moders-table" className="moders-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Логин</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{moders.length === 0 && (
|
||||
<tr><td colSpan="3">Нет модераторов</td></tr>
|
||||
)}
|
||||
{moders.map(moder => (
|
||||
<ModerRow
|
||||
key={moder.id}
|
||||
moder={moder}
|
||||
onDelete={handleDeleteModer}
|
||||
onResetPass={handleResetModerPass}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'monitoring' && (
|
||||
<div id="monitoring-tab">
|
||||
<h2>Мониторинг системы</h2>
|
||||
<div id="monitoring-status-container">
|
||||
{/* Статусы будут здесь */}
|
||||
</div>
|
||||
|
||||
<h3>Проблемные события (Warn/Error)</h3>
|
||||
<div className="monitoring-filters">
|
||||
<label>Уровень:
|
||||
<select id="log-level-filter" value={logLevelFilter} onChange={(e) => setLogLevelFilter(e.target.value)}>
|
||||
<option value="all">Все</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Источник:
|
||||
<select id="log-source-filter" value={logSourceFilter} onChange={(e) => setLogSourceFilter(e.target.value)}>
|
||||
<option value="all">Все</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Backend">Backend</option>
|
||||
<option value="CardReader">CardReader</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="refresh-monitoring-btn" className="btn-small" onClick={fetchMonitoringLogs}>Обновить</button>
|
||||
</div>
|
||||
<table id="monitoring-log-table" className="moders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '20%' }}>Время</th>
|
||||
<th style={{ width: '10%' }}>Уровень</th>
|
||||
<th style={{ width: '15%' }}>Источник</th>
|
||||
<th>Сообщение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMonitoringLogs.length === 0 && (
|
||||
<tr><td colSpan="4">Нет событий для отображения.</td></tr>
|
||||
)}
|
||||
{filteredMonitoringLogs.map((log, index) => (
|
||||
<tr key={index} className={getLogLevelClass(log.level)}>
|
||||
<td>{new Date(log.timestamp).toLocaleString()}</td>
|
||||
<td><span className="log-level-label">{log.level}</span></td>
|
||||
<td>{log.source}</td>
|
||||
<td>{log.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Moderator row component with inline password reset
|
||||
function ModerRow({ moder, onDelete, onResetPass }) {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [newPass, setNewPass] = useState('');
|
||||
|
||||
const handleSavePass = async () => {
|
||||
const success = await onResetPass(moder.id, newPass);
|
||||
if (success) {
|
||||
setIsResetting(false);
|
||||
setNewPass('');
|
||||
}
|
||||
};
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{moder.id}</td>
|
||||
<td>{moder.username}</td>
|
||||
<td>
|
||||
<input type="password" placeholder="Новый пароль" className="moder-pass-input" value={newPass} onChange={(e) => setNewPass(e.target.value)} />
|
||||
<button className="btn-small" onClick={handleSavePass}>✅</button>
|
||||
<button className="btn-small" onClick={() => { setIsResetting(false); setNewPass(''); }}>❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{moder.id}</td>
|
||||
<td>{moder.username}</td>
|
||||
<td>
|
||||
<button className="btn-small" onClick={() => setIsResetting(true)} title="Сменить пароль">🔑</button>{' '}
|
||||
<button className="btn-small" onClick={() => {
|
||||
if (window.confirm('Удалить модератора?')) onDelete(moder.id);
|
||||
}} title="Удалить">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import '../App.css'; // Assuming you move the CSS here
|
||||
|
||||
function Login() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
setErrorMessage('Неверный пароль');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Вход</h1>
|
||||
<form id="login-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
{errorMessage && <p id="error-message" style={{ color: 'red' }}>{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -1,395 +0,0 @@
|
||||
// src/components/SlidesManager.jsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
function SlidesManager() {
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
image_url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
link: '',
|
||||
order: 0,
|
||||
is_active: 1
|
||||
});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
|
||||
const showNotify = useCallback((message, type = 'success') => {
|
||||
setNotify({ message, type, show: true });
|
||||
setTimeout(() => {
|
||||
setNotify({ message: '', type: '', show: false });
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
// Загрузка слайдов
|
||||
const loadSlides = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/moders/content/slides/all', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSlides(Array.isArray(data) ? data : []);
|
||||
} else {
|
||||
showNotify('Ошибка загрузки слайдов', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки слайдов', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSlides();
|
||||
}, [loadSlides]);
|
||||
|
||||
// Открыть модалку для добавления
|
||||
const handleAddSlide = () => {
|
||||
setEditingSlide(null);
|
||||
setFormData({
|
||||
image_url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
link: '',
|
||||
order: slides.length,
|
||||
is_active: 1
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Открыть модалку для редактирования
|
||||
const handleEditSlide = (slide) => {
|
||||
setEditingSlide(slide);
|
||||
setFormData({
|
||||
image_url: slide.image_url || '',
|
||||
title: slide.title || '',
|
||||
description: slide.description || '',
|
||||
link: slide.link || '',
|
||||
order: slide.order || 0,
|
||||
is_active: slide.is_active !== undefined ? slide.is_active : 1
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Сохранить слайд
|
||||
// В функции handleSaveSlide
|
||||
const handleSaveSlide = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// ✅ ЛОГИ ДЛЯ ОТЛАДКИ
|
||||
console.log('========================================');
|
||||
console.log('📤 ОТПРАВКА ДАННЫХ НА СЕРВЕР');
|
||||
console.log('📤 editingSlide:', editingSlide);
|
||||
console.log('📤 formData:', JSON.stringify(formData, null, 2));
|
||||
console.log('========================================');
|
||||
|
||||
if (!formData.image_url) {
|
||||
showNotify('URL изображения обязателен', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = editingSlide
|
||||
? `/moders/content/slides/${editingSlide.id}`
|
||||
: '/moders/content/slides';
|
||||
|
||||
const method = editingSlide ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log('📥 ОТВЕТ СЕРВЕРА:', res.status, data);
|
||||
|
||||
if (res.ok) {
|
||||
showNotify(editingSlide ? 'Слайд обновлён' : 'Слайд добавлен', 'success');
|
||||
setShowEditModal(false);
|
||||
loadSlides();
|
||||
} else {
|
||||
showNotify(data.message || `Ошибка: ${res.status}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка:', e);
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить слайд
|
||||
const handleDeleteSlide = async (id) => {
|
||||
if (!window.confirm('Удалить этот слайд?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/moders/content/slides/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify('Слайд удалён', 'success');
|
||||
loadSlides();
|
||||
} else {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка изображения
|
||||
const handleUploadImage = async (file) => {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await fetch('/moders/content/slides/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFormData(prev => ({ ...prev, image_url: data.path }));
|
||||
showNotify('Изображение загружено', 'success');
|
||||
} else {
|
||||
showNotify('Ошибка загрузки изображения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки изображения', 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="slides-manager">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2>📸 Слайды карусели</h2>
|
||||
<button id="add-slide-btn" onClick={handleAddSlide}>+ Добавить слайд</button>
|
||||
</div>
|
||||
|
||||
{/* Уведомления */}
|
||||
{notify.show && (
|
||||
<div className={`notify ${notify.type}`} style={{
|
||||
padding: '10px 16px',
|
||||
marginBottom: '15px',
|
||||
borderRadius: '8px',
|
||||
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
|
||||
color: notify.type === 'success' ? '#155724' : '#721c24',
|
||||
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
|
||||
}}>
|
||||
{notify.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список слайдов */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>Загрузка...</div>
|
||||
) : slides.length === 0 ? (
|
||||
<p style={{ color: '#999', textAlign: 'center', padding: '40px' }}>Нет слайдов. Добавьте первый!</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
|
||||
{slides.map(slide => (
|
||||
<div key={slide.id} style={{
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: `2px solid ${slide.is_active ? '#28a745' : '#dc3545'}`,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<img
|
||||
src={slide.image_url}
|
||||
alt={slide.title || 'Слайд'}
|
||||
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3E%D0%9D%D0%B5%D1%82%20%D0%B8%D0%B7%D0%BE%D0%B1%D1%80%D0%B0%D0%B6%D0%B5%D0%BD%D0%B8%D1%8F%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '16px' }}>{slide.title || 'Без названия'}</h4>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#666', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{slide.description || ''}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '10px' }}>
|
||||
<span>Порядок: {slide.order}</span>
|
||||
<span>•</span>
|
||||
<span style={{ color: slide.is_active ? '#28a745' : '#dc3545' }}>
|
||||
{slide.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn-small" onClick={() => handleEditSlide(slide)}>✏️</button>
|
||||
<button className="btn-small" onClick={() => handleDeleteSlide(slide.id)}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования */}
|
||||
{showEditModal && (
|
||||
<div className="modal" style={{
|
||||
display: 'block',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 2000,
|
||||
padding: '20px',
|
||||
overflowY: 'auto'
|
||||
}} onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowEditModal(false);
|
||||
}}>
|
||||
<div className="modal-content" style={{
|
||||
maxWidth: '700px',
|
||||
margin: '40px auto',
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
padding: '30px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<button className="close" style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '20px',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#999'
|
||||
}} onClick={() => setShowEditModal(false)}>×</button>
|
||||
|
||||
<h3 style={{ marginBottom: '20px' }}>
|
||||
{editingSlide ? 'Редактировать слайд' : 'Добавить слайд'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSaveSlide}>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>
|
||||
Изображение URL или загрузить:
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.image_url}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, image_url: e.target.value }))}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
style={{ flex: 1, padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
<label style={{
|
||||
padding: '8px 16px',
|
||||
background: '#6c757d',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
{uploading ? 'Загрузка...' : '📤 Загрузить'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUploadImage(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Заголовок:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Заголовок слайда"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Описание:</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Описание слайда"
|
||||
rows="3"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Ссылка (опционально):</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.link}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, link: e.target.value }))}
|
||||
placeholder="https://example.com"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '15px' }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Порядок:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
|
||||
min="0"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Статус:</label>
|
||||
<select
|
||||
value={formData.is_active}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, is_active: parseInt(e.target.value) }))}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
|
||||
>
|
||||
<option value="1">Активен</option>
|
||||
<option value="0">Неактивен</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" style={{
|
||||
padding: '10px 30px',
|
||||
background: '#6c5ce7',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{editingSlide ? 'Обновить' : 'Добавить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlidesManager;
|
||||
@@ -1,13 +0,0 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.jsx', '.js', '.tsx', '.ts', '.json']
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
PORT=3005
|
||||
DB_USER=prog
|
||||
DB_PASSWORD=prog
|
||||
DB_SERVER=192.168.31.34
|
||||
DB_NAME=ASU
|
||||
|
||||
BACKEND_URL=http://localhost:3001
|
||||
ADMIN_URL=http://localhost:3005
|
||||
ADMIN_PASSWORD=2
|
||||
SECRET_KEY=njdsn30423423fndjjfsn34u
|
||||
LOGS_DIR=../backend/logs
|
||||
CARD_LOG_PATH=../backend/logs/cards.log
|
||||
ADMIN_LOG=./logs/admin.log
|
||||
ADMIN_LOG_DIR=../logs
|
||||
ALLOWED_ORIGINS=http://localhost:3005, http://localhost:3001
|
||||
BACKEND_APP_LOG=../backend/logs/app.log
|
||||
CARD_LOG=../backend/logs/cards.log
|
||||
@@ -0,0 +1,12 @@
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim());
|
||||
|
||||
module.exports = {
|
||||
origin: function(origin, callback) {
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
return callback(new Error('CORS: Origin not allowed'), false);
|
||||
},
|
||||
credentials: true
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
const session = require('express-session');
|
||||
const MySQLStore = require('express-mysql-session')(session);
|
||||
|
||||
module.exports = (session) => {
|
||||
return {
|
||||
secret: process.env.SECRET_KEY || 'mhmh4563463fds',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new MySQLStore({
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: process.env.MYSQL_PORT,
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE
|
||||
}),
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
maxAge: 10 * 60 * 60 * 1000
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
const logger = require('../logger');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moderDb = require('../moders/db');
|
||||
require('dotenv').config({ path: __dirname + './config.env' });
|
||||
exports.login = (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (password === process.env.ADMIN_PASSWORD) {
|
||||
req.session.isAuthenticated = true;
|
||||
logger.info('Login successful');
|
||||
res.status(200).send('Login successful');
|
||||
} else {
|
||||
res.status(401).send('Invalid password');
|
||||
logger.warn('Неверный пароль');
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCsrfToken = (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
};
|
||||
|
||||
exports.moderLogin = async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
const [users] = await moderDb.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ message: 'Неверные учетные данные' });
|
||||
}
|
||||
const user = users[0];
|
||||
const match = await bcrypt.compare(password, user.password_hash);
|
||||
if (!match) {
|
||||
return res.status(401).json({ message: 'Неверные учетные данные' });
|
||||
}
|
||||
req.session.moderId = user.id;
|
||||
req.session.moderName = user.username;
|
||||
res.json({ message: 'Успешный вход', username: user.username });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка входа модератора:', error);
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
const CARD_LOG_PATH = path.resolve(__dirname, '..', process.env.CARD_LOG_PATH);
|
||||
const initCardLogs = async () => {
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(CARD_LOG_PATH));
|
||||
if (!(await fs.pathExists(CARD_LOG_PATH))) {
|
||||
await fs.writeFile(CARD_LOG_PATH, '');
|
||||
logger.info(`Card log file created at: ${CARD_LOG_PATH}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize card logs:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
initCardLogs().catch(err => {
|
||||
logger.error('Card logs initialization error:', err);
|
||||
});
|
||||
|
||||
const cardEvent = (req, res) => {
|
||||
logger.info('[CARD EVENT] Received:', req.body);
|
||||
|
||||
if (!req.body || !req.body.event) {
|
||||
logger.error('Invalid request body:', req.body);
|
||||
return res.status(400).json({ message: 'Missing required field: event' });
|
||||
}
|
||||
|
||||
const { event, uid = 'N/A', timestamp = Date.now() } = req.body;
|
||||
const logLine = `[${new Date(timestamp).toISOString()}] [${event}] UID: ${uid}\n`;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(CARD_LOG_PATH)) {
|
||||
initCardLogs();
|
||||
}
|
||||
fs.appendFileSync(CARD_LOG_PATH, logLine);
|
||||
logger.info('[CARD EVENT] Log written to:', CARD_LOG_PATH);
|
||||
return res.status(200).json({ message: 'OK', path: CARD_LOG_PATH });
|
||||
|
||||
} catch (err) {
|
||||
logger.error('[CARD EVENT] Failed to write log:', err);
|
||||
return res.status(500).json({
|
||||
message: 'Failed to write log',
|
||||
error: err.message,
|
||||
path: CARD_LOG_PATH
|
||||
});
|
||||
}
|
||||
};
|
||||
const getCardLogs = async (req, res) => {
|
||||
try {
|
||||
if (!(await fs.pathExists(CARD_LOG_PATH))) {
|
||||
await initCardLogs();
|
||||
return res.status(200).type('text/plain').send('Card logs initialized');
|
||||
}
|
||||
const logs = await fs.readFile(CARD_LOG_PATH, 'utf-8');
|
||||
res.type('text/plain').send(logs);
|
||||
} catch (err) {
|
||||
logger.error('Error reading card logs:', err);
|
||||
res.status(500).json({
|
||||
error: err.message,
|
||||
path: CARD_LOG_PATH
|
||||
});
|
||||
}
|
||||
};
|
||||
module.exports = {
|
||||
cardEvent,
|
||||
getCardLogs,
|
||||
initCardLogs
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
const axios = require('axios');
|
||||
const WebSocket = require('ws');
|
||||
const iconv = require('iconv-lite');
|
||||
const logger = require('../logger')
|
||||
exports.checkDb = async (req, res) => {
|
||||
let adminToDbStatus = { status: 'offline', message: 'Test not performed' };
|
||||
try {
|
||||
const dbConfig = {
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
server: process.env.DB_SERVER,
|
||||
database: process.env.DB_NAME,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
pool: { max: 1, min: 0, idleTimeoutMillis: 5000 },
|
||||
requestTimeout: 5000
|
||||
};
|
||||
const pool = new sql.ConnectionPool(dbConfig);
|
||||
await pool.connect();
|
||||
await pool.query`SELECT 1`;
|
||||
adminToDbStatus = { status: 'online', message: 'Прямое подключение успешно' };
|
||||
await pool.close();
|
||||
} catch (error) {
|
||||
adminToDbStatus = { status: 'offline', message: `Ошибка прямого подключения: ${error.message}` };
|
||||
}
|
||||
|
||||
let backendToDbStatus = { status: 'offline', message: 'Test not performed' };
|
||||
try {
|
||||
const response = await axios.get(`${process.env.BACKEND_URL}/api/db-status`, { timeout: 3000 });
|
||||
if (response.data.status === 'ok') {
|
||||
backendToDbStatus = { status: 'online', message: 'Бэкенд успешно подключен' };
|
||||
} else {
|
||||
backendToDbStatus = { status: 'offline', message: 'Бэкенд сообщил об ошибке' };
|
||||
}
|
||||
} catch (error) {
|
||||
backendToDbStatus = { status: 'offline', message: 'Не удалось связаться с бэкендом' };
|
||||
}
|
||||
|
||||
res.json({
|
||||
adminToDb: adminToDbStatus,
|
||||
backendToDb: backendToDbStatus
|
||||
});
|
||||
};
|
||||
|
||||
exports.checkStatus = async (req, res) => {
|
||||
try {
|
||||
await axios.get(`${process.env.BACKEND_URL}/api/test`, { timeout: 2000 });
|
||||
res.json({ status: 'online' });
|
||||
} catch (error) {
|
||||
res.json({ status: 'offline' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
const ADMIN_LOG = path.resolve(__dirname, '..', process.env.ADMIN_LOG);
|
||||
const BACKEND_APP_LOG = path.resolve(__dirname, '..', process.env.BACKEND_APP_LOG);
|
||||
const CARD_LOG = path.resolve(__dirname, '..', process.env.CARD_LOG);
|
||||
|
||||
const getLogs = async (req, res) => {
|
||||
try {
|
||||
const logPath = path.join(__dirname, '..', '../backend/logs/app.log');
|
||||
const logs = await fs.readFile(logPath, 'utf-8');
|
||||
res.type('text/plain');
|
||||
res.send(logs);
|
||||
} catch (error) {
|
||||
logger.error('Error reading log file:', error);
|
||||
res.status(500).send('Could not read log file.');
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async (req, res) => {
|
||||
try {
|
||||
const logPath = path.join(__dirname, '..', '../backend/logs/app.log');
|
||||
await fs.truncate(logPath, 0);
|
||||
res.status(200).json({ message: 'Logs cleared successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error clearing log file:', error);
|
||||
res.status(500).send('Could not clear log file.');
|
||||
}
|
||||
};
|
||||
|
||||
const getMonitoringLogs = async (req, res) => {
|
||||
const logSources = [
|
||||
{ name: 'Admin', path: ADMIN_LOG },
|
||||
{ name: 'Backend', path: BACKEND_APP_LOG },
|
||||
{ name: 'CardReader', path: CARD_LOG }
|
||||
];
|
||||
|
||||
let allEvents = [];
|
||||
|
||||
for (const source of logSources) {
|
||||
try {
|
||||
if (!(await fs.pathExists(source.path))) {
|
||||
logger.warn(`Log file not found: ${source.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(source.path, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
lines.forEach(line => {
|
||||
if (!line) return;
|
||||
try {
|
||||
const jsonLog = JSON.parse(line);
|
||||
if (['warn', 'error'].includes(jsonLog.level?.toLowerCase())) {
|
||||
allEvents.push({
|
||||
timestamp: jsonLog.timestamp || new Date().toISOString(),
|
||||
level: jsonLog.level.toUpperCase(),
|
||||
source: source.name,
|
||||
message: jsonLog.message
|
||||
});
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
}
|
||||
const match = line.match(/\[([^\]]+)\] \[([^\]]+)\] (.*)/);
|
||||
if (match) {
|
||||
const level = match[2].toLowerCase();
|
||||
if (['warn', 'error'].includes(level)) {
|
||||
allEvents.push({
|
||||
timestamp: new Date(match[1]).toISOString(),
|
||||
level: level.toUpperCase(),
|
||||
source: source.name,
|
||||
message: match[3]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error reading or parsing log file ${source.path}:`, err);
|
||||
allEvents.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'ERROR',
|
||||
source: 'Monitoring',
|
||||
message: `Could not read log file: ${source.name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allEvents.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
res.json(allEvents);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getLogs,
|
||||
clearLogs,
|
||||
getMonitoringLogs
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
const moderDb = require('../moders/db');
|
||||
const bcrypt = require('bcrypt');
|
||||
const logger = require('../logger');
|
||||
|
||||
const getModers = async (req, res) => {
|
||||
try {
|
||||
const [rows] = await moderDb.query('SELECT id, username FROM users ORDER BY id ASC');
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
logger.error('Ошибка получения списка модераторов:', e);
|
||||
res.status(500).json({ message: 'Ошибка получения списка модераторов' });
|
||||
}
|
||||
};
|
||||
|
||||
const addModer = async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: 'username и password обязательны' });
|
||||
}
|
||||
try {
|
||||
const [exists] = await moderDb.query('SELECT id FROM users WHERE username = ?', [username]);
|
||||
if (exists.length) {
|
||||
return res.status(409).json({ message: 'Пользователь с таким username уже существует' });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const [result] = await moderDb.query('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, hash]);
|
||||
res.json({ id: result.insertId, username });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка добавления модератора:', e);
|
||||
res.status(500).json({ message: 'Ошибка добавления модератора' });
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { password } = req.body;
|
||||
if (!password) {
|
||||
return res.status(400).json({ message: 'password обязателен' });
|
||||
}
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const [result] = await moderDb.query('UPDATE users SET password_hash = ? WHERE id = ?', [hash, id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Модератор не найден' });
|
||||
}
|
||||
res.json({ message: 'Пароль обновлён' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка смены пароля модератора:', e);
|
||||
res.status(500).json({ message: 'Ошибка смены пароля модератора' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModer = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const [result] = await moderDb.query('DELETE FROM users WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Модератор не найден' });
|
||||
}
|
||||
res.json({ message: 'Модератор удалён' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка удаления модератора:', e);
|
||||
res.status(500).json({ message: 'Ошибка удаления модератора' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getModers,
|
||||
addModer,
|
||||
changePassword,
|
||||
deleteModer
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
const configEnvPath = path.join(__dirname, '..', 'config.env');
|
||||
const modersEnvPath = path.join(__dirname, '..', 'moders', 'config.env');
|
||||
const backendConfigPath = path.join(__dirname, '..', '../backend/config.json');
|
||||
|
||||
const parseEnv = async (filePath) => {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const result = {};
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const updateEnv = async (filePath, updates) => {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const keys = Object.keys(updates);
|
||||
const updated = new Set();
|
||||
const newLines = lines.map(line => {
|
||||
if (!line.trim() || line.trim().startsWith('#') || !line.includes('=')) return line;
|
||||
const idx = line.indexOf('=');
|
||||
const key = line.slice(0, idx).trim();
|
||||
if (keys.includes(key)) {
|
||||
updated.add(key);
|
||||
return `${key}=${updates[key]}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
for (const key of keys) {
|
||||
if (!updated.has(key)) {
|
||||
newLines.push(`${key}=${updates[key]}`);
|
||||
}
|
||||
}
|
||||
await fs.writeFile(filePath, newLines.join('\n'), 'utf-8');
|
||||
};
|
||||
|
||||
const getMysqlSettings = async (req, res) => {
|
||||
try {
|
||||
const env = await parseEnv(modersEnvPath);
|
||||
res.json({
|
||||
MYSQL_HOST: env.MYSQL_HOST || '',
|
||||
MYSQL_PORT: env.MYSQL_PORT || '',
|
||||
MYSQL_USER: env.MYSQL_USER || '',
|
||||
MYSQL_PASSWORD: env.MYSQL_PASSWORD || '',
|
||||
MYSQL_DATABASE: env.MYSQL_DATABASE || ''
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Ошибка чтения moders/config.env:', e);
|
||||
res.status(500).json({ message: 'Ошибка чтения moders/config.env' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateMysqlSettings = async (req, res) => {
|
||||
const updates = req.body;
|
||||
if (updates.MYSQL_PASSWORD === '') {
|
||||
delete updates.MYSQL_PASSWORD;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ message: 'Нет данных для обновления' });
|
||||
}
|
||||
try {
|
||||
await updateEnv(modersEnvPath, updates);
|
||||
res.json({ message: 'Параметры MySQL обновлены' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка записи moders/config.env:', e);
|
||||
res.status(500).json({ message: 'Ошибка записи moders/config.env' });
|
||||
}
|
||||
};
|
||||
|
||||
const getCorsSettings = async (req, res) => {
|
||||
try {
|
||||
const env = await parseEnv(configEnvPath);
|
||||
const origins = (env.ALLOWED_ORIGINS || '').split(',').map(o => o.trim()).filter(Boolean);
|
||||
res.json({ ALLOWED_ORIGINS: origins });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка чтения ALLOWED_ORIGINS:', e);
|
||||
res.status(500).json({ message: 'Ошибка чтения ALLOWED_ORIGINS' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateCorsSettings = async (req, res) => {
|
||||
const { ALLOWED_ORIGINS } = req.body;
|
||||
if (!Array.isArray(ALLOWED_ORIGINS) || !ALLOWED_ORIGINS.length) {
|
||||
return res.status(400).json({ message: 'Список ALLOWED_ORIGINS обязателен' });
|
||||
}
|
||||
try {
|
||||
await updateEnv(configEnvPath, { ALLOWED_ORIGINS: ALLOWED_ORIGINS.join(', ') });
|
||||
res.json({ message: 'ALLOWED_ORIGINS обновлён' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка записи ALLOWED_ORIGINS:', e);
|
||||
res.status(500).json({ message: 'Ошибка записи ALLOWED_ORIGINS' });
|
||||
}
|
||||
};
|
||||
|
||||
const getAdminServerSettings = async (req, res) => {
|
||||
try {
|
||||
const env = await parseEnv(configEnvPath);
|
||||
res.json({
|
||||
PORT: env.PORT || '',
|
||||
ADMIN_URL: env.ADMIN_URL || ''
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Ошибка чтения config.env (admin-server):', e);
|
||||
res.status(500).json({ message: 'Ошибка чтения config.env' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateAdminServerSettings = async (req, res) => {
|
||||
const { PORT, ADMIN_URL } = req.body;
|
||||
if (!PORT || !ADMIN_URL) {
|
||||
return res.status(400).json({ message: 'PORT и ADMIN_URL обязательны' });
|
||||
}
|
||||
try {
|
||||
await updateEnv(configEnvPath, { PORT, ADMIN_URL });
|
||||
res.json({ message: 'Параметры admin-server обновлены' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка записи config.env (admin-server):', e);
|
||||
res.status(500).json({ message: 'Ошибка записи config.env' });
|
||||
}
|
||||
};
|
||||
|
||||
const getBackendServerSettings = async (req, res) => {
|
||||
try {
|
||||
const config = await fs.readJson(backendConfigPath);
|
||||
res.json({
|
||||
host: config.server?.host || '',
|
||||
port: config.server?.port || ''
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Ошибка чтения backend/config.json:', e);
|
||||
res.status(500).json({ message: 'Ошибка чтения backend/config.json' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateBackendServerSettings = async (req, res) => {
|
||||
const { host, port } = req.body;
|
||||
if (!host || !port) {
|
||||
return res.status(400).json({ message: 'host и port обязательны' });
|
||||
}
|
||||
try {
|
||||
const config = await fs.readJson(backendConfigPath);
|
||||
config.server = config.server || {};
|
||||
config.server.host = host;
|
||||
config.server.port = port;
|
||||
await fs.writeJson(backendConfigPath, config, { spaces: 4 });
|
||||
res.json({ message: 'Параметры backend-server обновлены' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка записи backend/config.json:', e);
|
||||
res.status(500).json({ message: 'Ошибка записи backend/config.json' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMssqlSettings,
|
||||
updateMssqlSettings,
|
||||
getMysqlSettings,
|
||||
updateMysqlSettings,
|
||||
getCorsSettings,
|
||||
updateCorsSettings,
|
||||
getAdminServerSettings,
|
||||
updateAdminServerSettings,
|
||||
getBackendServerSettings,
|
||||
updateBackendServerSettings
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const LOGS_DIR = path.join(__dirname, 'logs');
|
||||
const LOG_FILE = path.join(LOGS_DIR, 'admin.log');
|
||||
const MAX_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function rotateLogIfNeeded() {
|
||||
try {
|
||||
if (fs.existsSync(LOG_FILE)) {
|
||||
const stats = fs.statSync(LOG_FILE);
|
||||
if (stats.size >= MAX_SIZE) {
|
||||
const date = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const archiveName = `admin-${date}.log`;
|
||||
const archivePath = path.join(LOGS_DIR, archiveName);
|
||||
fs.renameSync(LOG_FILE, archivePath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LOGGER] Ошибка ротации лога:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function format(level, message) {
|
||||
return `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`;
|
||||
}
|
||||
|
||||
function log(level, ...args) {
|
||||
const msg = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
||||
const line = format(level, msg);
|
||||
rotateLogIfNeeded();
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, line);
|
||||
} catch (err) {
|
||||
console.error('[LOGGER] Ошибка записи в лог:', err);
|
||||
}
|
||||
if (level === 'error') {
|
||||
console.error(line.trim());
|
||||
} else if (level === 'warn') {
|
||||
console.warn(line.trim());
|
||||
} else {
|
||||
console.log(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
info: (...args) => log('info', ...args),
|
||||
warn: (...args) => log('warn', ...args),
|
||||
error: (...args) => log('error', ...args),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = (req, res, next) => {
|
||||
if (req.session && req.session.isAuthenticated) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// middleware/csrf.js
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
const csrfMiddleware = (req, res, next) => {
|
||||
if (
|
||||
req.path === '/login' ||
|
||||
req.path === '/moders/moder-login' ||
|
||||
req.path === '/moders/csrf-token'
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return csrfProtection(req, res, next);
|
||||
};
|
||||
|
||||
module.exports = csrfMiddleware;
|
||||
@@ -0,0 +1,8 @@
|
||||
const logger = require('../logger')
|
||||
module.exports = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN') {
|
||||
return res.status(403).json({ message: 'Invalid CSRF token' });
|
||||
}
|
||||
logger.error(err.stack);
|
||||
res.status(500).json({ message: 'Internal Server Error' });
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=Vova-2003.
|
||||
MYSQL_DATABASE=rasch_content
|
||||
@@ -0,0 +1,20 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config({ path: __dirname + './config.env' });
|
||||
console.log({
|
||||
host: process.env.MYSQL_HOST,
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE
|
||||
});
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: process.env.MYSQL_PORT || 3306,
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
@@ -0,0 +1,6 @@
|
||||
function requireModerator(req, res, next) {
|
||||
if (req.session && req.session.isModerator) return next();
|
||||
res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
module.exports = requireModerator;
|
||||
@@ -0,0 +1,100 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const requireModerator = require('../middleware/requireModerator');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../uploads');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
|
||||
cb(null, name);
|
||||
}
|
||||
});
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png',
|
||||
'video/mp4', 'video/quicktime'
|
||||
];
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Недопустимый тип файла'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Получить все блоки для вкладки
|
||||
router.get('/:tab', async (req, res) => {
|
||||
const { tab } = req.params;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка файла (image/video)
|
||||
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
|
||||
const relPath = '/uploads/' + req.file.filename;
|
||||
res.json({ path: relPath });
|
||||
});
|
||||
|
||||
// Добавить новый блок
|
||||
router.post('/:tab', requireModerator, async (req, res) => {
|
||||
const { tab } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||
[tab, 'text', content, req.session.userId]
|
||||
);
|
||||
res.json({ id: result.insertId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить блок
|
||||
router.put('/:tab/:id', requireModerator, async (req, res) => {
|
||||
const { tab, id } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query(
|
||||
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
|
||||
[content, id, tab]
|
||||
);
|
||||
res.json({ message: 'Обновлено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить блок
|
||||
router.delete('/:tab/:id', requireModerator, async (req, res) => {
|
||||
const { tab, id } = req.params;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
|
||||
res.json({ message: 'Удалено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const logger = require('../../logger');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 30 * 60 * 1000, // 30 минут
|
||||
max: 10,
|
||||
message: { message: 'Слишком много попыток входа. Попробуйте позже.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
router.get('/csrf-token', csrfProtection, (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
});
|
||||
|
||||
router.post('/moder-login', loginLimiter, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
logger.info(`[LOGIN] Попытка входа: ${username}`);
|
||||
try {
|
||||
logger.info('[LOGIN] Проверка подключения к базе...');
|
||||
const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||
logger.info(`[LOGIN] Результат поиска пользователя: ${rows.length > 0 ? 'найден' : 'не найден'}`);
|
||||
if (!rows.length) {
|
||||
logger.warn(`[LOGIN] Пользователь не найден: ${username}`);
|
||||
return res.status(401).json({ message: 'Неверный логин или пароль' });
|
||||
}
|
||||
const user = rows[0];
|
||||
if (!user.password_hash) {
|
||||
logger.error('[LOGIN] Отсутствует password_hash в БД');
|
||||
return res.status(500).json({ message: 'Ошибка сервера: некорректные данные пользователя' });
|
||||
}
|
||||
logger.info('[LOGIN] Сравнение пароля через bcrypt...');
|
||||
const match = await bcrypt.compare(password, user.password_hash);
|
||||
logger.info(`[LOGIN] Результат сравнения пароля: ${match}`);
|
||||
if (!match) {
|
||||
logger.warn(`[LOGIN] Неверный пароль для пользователя: ${username}`);
|
||||
return res.status(401).json({ message: 'Неверный логин или пароль' });
|
||||
}
|
||||
try {
|
||||
req.session.isModerator = true;
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
logger.info('[LOGIN] Сессия установлена:', {
|
||||
isModerator: req.session.isModerator,
|
||||
userId: req.session.userId,
|
||||
username: req.session.username
|
||||
});
|
||||
res.json({ message: 'OK' });
|
||||
logger.info('[LOGIN] Ответ отправлен');
|
||||
} catch (e) {
|
||||
logger.error('[LOGIN] Ошибка при установке сессии или отправке ответа:', e);
|
||||
res.status(500).json({ message: 'Ошибка сервера (сессия)' });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[LOGIN] Ошибка авторизации:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/moder-logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.json({ message: 'OK' });
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/check-session', (req, res) => {
|
||||
if (req.session && req.session.isModerator) {
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(401).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================
|
||||
-- 📦 Схема базы данных модераторов: rasch_content
|
||||
-- Таблицы: users (модераторы), content_blocks (контент), sessions
|
||||
-- Совместимо с MySQL 8.0+ (CHECK-ограничения требуют 8.0.16+)
|
||||
-- Для более старых версий закомментируйте строки с CHECK.
|
||||
--
|
||||
-- Запуск: mysql -u root -p < schema.sql
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS rasch_content
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE rasch_content;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Модераторы
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL, -- bcrypt-хэш (60+ символов)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Модерский контент (вкладки "Информация" и "Профсоюз")
|
||||
-- HTML-разметка из редактора Quill хранится в content
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS content_blocks (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tab VARCHAR(20) NOT NULL, -- 'info' | 'union'
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'text', -- пока только 'text'
|
||||
content MEDIUMTEXT NOT NULL, -- HTML
|
||||
created_by INT UNSIGNED DEFAULT NULL, -- FK -> users.id
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_content_blocks_tab (tab),
|
||||
KEY idx_content_blocks_created_by (created_by),
|
||||
CONSTRAINT fk_content_blocks_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES users (id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
-- Ограничения значений (MySQL 8.0.16+)
|
||||
CONSTRAINT chk_content_blocks_tab CHECK (tab IN ('info', 'union')),
|
||||
CONSTRAINT chk_content_blocks_type CHECK (type IN ('text'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Сессии (express-mysql-session)
|
||||
-- Точная структура из node_modules/express-mysql-session/schema.sql
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id varchar(128) COLLATE utf8mb4_bin NOT NULL,
|
||||
expires int(11) unsigned NOT NULL,
|
||||
data mediumtext COLLATE utf8mb4_bin,
|
||||
PRIMARY KEY (session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Демо-модератор
|
||||
-- Пароль: "moderator123" (bcrypt, соль 10 раундов)
|
||||
-- После создания измените пароль через админ-панель.
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO users (username, password_hash) VALUES
|
||||
('moderator', '$2b$10$7EqJtq98hPqEX7fNZaFWoOhi5SzuYrGTgEdf762vPz0J8wD0GmKMS');
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "admin-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"electron": "electron .",
|
||||
"electron-dev": "set NODE_ENV=development&& electron .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"csurf": "^1.11.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"electron": "^37.0.0",
|
||||
"express": "^5.1.0",
|
||||
"express-mysql-session": "^3.0.3",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"express-session": "^1.18.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"morgan": "^1.10.0",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"mysql2": "^3.14.2",
|
||||
"quill": "^2.0.3",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statusElement = document.getElementById('server-status');
|
||||
const refreshBtn = document.getElementById('refresh-status-btn');
|
||||
const logsElement = document.getElementById('logs-content');
|
||||
const configForm = document.getElementById('config-form');
|
||||
const payslipsPathInput = document.getElementById('payslipsPath');
|
||||
const configStatus = document.getElementById('config-status');
|
||||
const browseBtn = document.getElementById('browse-btn');
|
||||
const dbStatusElement = document.getElementById('db-status');
|
||||
const checkDbBtn = document.getElementById('check-db-btn');
|
||||
const refreshLogsBtn = document.getElementById('refresh-logs-btn');
|
||||
const clearLogsBtn = document.getElementById('clear-logs-btn');
|
||||
const adminDbStatusEl = document.getElementById('admin-db-status');
|
||||
const adminDbMessageEl = document.getElementById('admin-db-message');
|
||||
const backendDbStatusEl = document.getElementById('backend-db-status');
|
||||
const backendDbMessageEl = document.getElementById('backend-db-message');
|
||||
const pingBtn = document.getElementById('ping-btn');
|
||||
const pingStopBtn = document.getElementById('ping-stop-btn');
|
||||
const pingClearBtn = document.getElementById('ping-clear-btn');
|
||||
const pingIpInput = document.getElementById('ping-ip');
|
||||
const pingOutputEl = document.getElementById('ping-output');
|
||||
let ws;
|
||||
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = loginForm.elements['password'].value;
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Неверный пароль');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка авторизации');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
fetch('/api/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'online') {
|
||||
statusElement.textContent = 'Онлайн';
|
||||
statusElement.style.color = 'green';
|
||||
} else {
|
||||
statusElement.textContent = 'Оффлайн';
|
||||
statusElement.style.color = 'red';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при получении статуса:', err);
|
||||
statusElement.textContent = 'Ошибка';
|
||||
statusElement.style.color = 'orange';
|
||||
});
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', fetchStatus);
|
||||
function fetchLogs() {
|
||||
logsElement.textContent = 'загрузка...';
|
||||
fetch('/api/logs')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи');
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
logsElement.textContent = data || 'Лог-файл пуст.';
|
||||
})
|
||||
.catch(err => {
|
||||
logsElement.textContent = `Ошибка при загрузке логов: ${err.message}`;
|
||||
logsElement.style.color = '#ff6b6b';
|
||||
});
|
||||
}
|
||||
function fetchConfig() {
|
||||
fetch('/api/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
payslipsPathInput.value = config.payslipsPath;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при получении конфигурации:', err);
|
||||
configStatus.textContent = 'Не удалось загрузить конфигурацию.';
|
||||
configStatus.style.color = 'red';
|
||||
});
|
||||
}
|
||||
|
||||
configForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const newConfig = {
|
||||
payslipsPath: payslipsPathInput.value
|
||||
};
|
||||
|
||||
apiFetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newConfig)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
configStatus.textContent = 'Конфигурация успешно сохранена!';
|
||||
configStatus.style.color = 'green';
|
||||
setTimeout(() => { configStatus.textContent = ''; }, 3000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при сохранении конфигурации:', err);
|
||||
configStatus.textContent = 'Ошибка при сохранении.';
|
||||
configStatus.style.color = 'red';
|
||||
});
|
||||
});
|
||||
browseBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/browse');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
payslipsPathInput.value = data.path;
|
||||
} else {
|
||||
console.info('Folder selection was canceled.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening folder dialog:', error);
|
||||
alert('Не удалось открыть диалог выбора папки.');
|
||||
}
|
||||
});
|
||||
|
||||
function renderStatus(element, messageEl, data) {
|
||||
messageEl.textContent = data.message;
|
||||
if (data.status === 'online') {
|
||||
element.textContent = 'Онлайн';
|
||||
element.style.color = 'green';
|
||||
} else {
|
||||
element.textContent = 'Оффлайн';
|
||||
element.style.color = 'red';
|
||||
}
|
||||
}
|
||||
function checkDbStatus() {
|
||||
adminDbStatusEl.textContent = 'проверка...';
|
||||
backendDbStatusEl.textContent = 'проверка...';
|
||||
adminDbMessageEl.textContent = '';
|
||||
backendDbMessageEl.textContent = '';
|
||||
adminDbStatusEl.style.color = '#333';
|
||||
backendDbStatusEl.style.color = '#333';
|
||||
|
||||
fetch('/api/health/check-db')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
renderStatus(adminDbStatusEl, adminDbMessageEl, data.adminToDb);
|
||||
renderStatus(backendDbStatusEl, backendDbMessageEl, data.backendToDb);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при проверке статуса БД:', err);
|
||||
adminDbStatusEl.textContent = 'Ошибка';
|
||||
adminDbStatusEl.style.color = 'orange';
|
||||
backendDbStatusEl.textContent = 'Ошибка';
|
||||
backendDbStatusEl.style.color = 'orange';
|
||||
});
|
||||
}
|
||||
|
||||
checkDbBtn.addEventListener('click', checkDbStatus);
|
||||
|
||||
function clearLogs() {
|
||||
if (!confirm('Это действие удалит лог-файл полностью и навсегда. Вы уверены?')) {
|
||||
return;
|
||||
}
|
||||
fetch('/api/logs/clear', { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
fetchLogs();
|
||||
} else {
|
||||
alert('Не удалось очистить логи.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при очистке логов:', err);
|
||||
alert('Произошла ошибка при очистке логов.');
|
||||
});
|
||||
}
|
||||
|
||||
refreshLogsBtn.addEventListener('click', fetchLogs);
|
||||
clearLogsBtn.addEventListener('click', clearLogs);
|
||||
|
||||
function doPing() {
|
||||
const ip = pingIpInput.value.trim();
|
||||
if (!ip) {
|
||||
pingOutputEl.textContent = 'Пожалуйста, введите IP-адрес.';
|
||||
return;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${wsProtocol}://${window.location.host}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
pingOutputEl.textContent = '';
|
||||
pingBtn.disabled = true;
|
||||
pingStopBtn.disabled = false;
|
||||
ws.send(JSON.stringify({ type: 'ping', ip }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
pingOutputEl.textContent += message.data;
|
||||
pingOutputEl.scrollTop = pingOutputEl.scrollHeight;
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket Error:', error);
|
||||
pingOutputEl.textContent += '\nОшибка WebSocket соединения.';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.info('WebSocket connection closed');
|
||||
pingBtn.disabled = false;
|
||||
pingStopBtn.disabled = true;
|
||||
ws = null;
|
||||
};
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPingOutput() {
|
||||
pingOutputEl.textContent = '';
|
||||
}
|
||||
pingBtn.addEventListener('click', doPing);
|
||||
pingStopBtn.addEventListener('click', stopPing);
|
||||
pingClearBtn.addEventListener('click', clearPingOutput);
|
||||
|
||||
const cardLogsElement = document.getElementById('card-logs-content');
|
||||
const refreshCardLogsBtn = document.getElementById('refresh-card-logs-btn');
|
||||
|
||||
function fetchCardLogs() {
|
||||
cardLogsElement.textContent = 'загрузка...';
|
||||
fetch('/api/card/card-logs')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи считывателя');
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
cardLogsElement.textContent = data || 'Лог-файл пуст.';
|
||||
})
|
||||
.catch(err => {
|
||||
cardLogsElement.textContent = `Ошибка при загрузке логов: ${err.message}`;
|
||||
cardLogsElement.style.color = '#ff6b6b';
|
||||
});
|
||||
}
|
||||
refreshCardLogsBtn.addEventListener('click', fetchCardLogs);
|
||||
|
||||
// --- ТАБЫ ---
|
||||
const tabMainBtn = document.getElementById('tab-main');
|
||||
const tabSettingsBtn = document.getElementById('tab-settings');
|
||||
const tabMonitoringBtn = document.getElementById('tab-monitoring');
|
||||
const mainTab = document.getElementById('main-tab');
|
||||
const settingsTab = document.getElementById('settings-tab');
|
||||
const monitoringTab = document.getElementById('monitoring-tab');
|
||||
|
||||
function showTab(tab) {
|
||||
mainTab.style.display = 'none';
|
||||
settingsTab.style.display = 'none';
|
||||
monitoringTab.style.display = 'none';
|
||||
tabMainBtn.classList.remove('active');
|
||||
tabSettingsBtn.classList.remove('active');
|
||||
tabMonitoringBtn.classList.remove('active');
|
||||
|
||||
if (tab === 'main') {
|
||||
mainTab.style.display = '';
|
||||
tabMainBtn.classList.add('active');
|
||||
} else if (tab === 'settings') {
|
||||
settingsTab.style.display = '';
|
||||
tabSettingsBtn.classList.add('active');
|
||||
initSettingsTab();
|
||||
} else if (tab === 'monitoring') {
|
||||
monitoringTab.style.display = '';
|
||||
tabMonitoringBtn.classList.add('active');
|
||||
initMonitoringTab();
|
||||
}
|
||||
}
|
||||
tabMainBtn.addEventListener('click', () => showTab('main'));
|
||||
tabSettingsBtn.addEventListener('click', () => showTab('settings'));
|
||||
tabMonitoringBtn.addEventListener('click', () => showTab('monitoring'));
|
||||
|
||||
// --- TOAST ---
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => container.removeChild(toast), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- CSRF TOKEN ---
|
||||
let csrfToken = null;
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const res = await fetch('/moders/csrf-token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
csrfToken = data.csrfToken;
|
||||
}
|
||||
} catch (e) { csrfToken = null; }
|
||||
}
|
||||
// Обёртка для fetch с CSRF и credentials
|
||||
async function apiFetch(url, options = {}) {
|
||||
if (!options.headers) options.headers = {};
|
||||
if (['POST','PUT','DELETE'].includes((options.method||'GET').toUpperCase())) {
|
||||
if (!csrfToken) await fetchCsrfToken();
|
||||
options.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
options.credentials = 'include';
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// --- Модальное окно подтверждения ---
|
||||
const confirmModal = document.getElementById('confirm-modal');
|
||||
const confirmModalText = document.getElementById('confirm-modal-text');
|
||||
const confirmModalYes = document.getElementById('confirm-modal-yes');
|
||||
const confirmModalNo = document.getElementById('confirm-modal-no');
|
||||
|
||||
function showConfirm(text, onConfirm) {
|
||||
confirmModalText.textContent = text;
|
||||
confirmModal.style.display = 'flex';
|
||||
|
||||
const yesListener = () => {
|
||||
onConfirm();
|
||||
closeConfirm();
|
||||
};
|
||||
|
||||
const closeConfirm = () => {
|
||||
confirmModal.style.display = 'none';
|
||||
confirmModalYes.removeEventListener('click', yesListener);
|
||||
confirmModalNo.removeEventListener('click', closeConfirm);
|
||||
}
|
||||
|
||||
confirmModalYes.addEventListener('click', yesListener);
|
||||
confirmModalNo.addEventListener('click', closeConfirm);
|
||||
}
|
||||
|
||||
|
||||
// --- ВКЛАДКА МОНИТОРИНГА ---
|
||||
let monitoringInitialized = false;
|
||||
let allMonitoringEvents = []; // Кэш для всех событий
|
||||
|
||||
async function initMonitoringTab() {
|
||||
if (monitoringInitialized) return;
|
||||
monitoringInitialized = true;
|
||||
|
||||
const tableBody = document.querySelector('#monitoring-log-table tbody');
|
||||
const levelFilter = document.getElementById('log-level-filter');
|
||||
const sourceFilter = document.getElementById('log-source-filter');
|
||||
const refreshBtn = document.getElementById('refresh-monitoring-btn');
|
||||
|
||||
async function loadMonitoringLogs() {
|
||||
tableBody.innerHTML = '<tr><td colspan="4">Загрузка...</td></tr>';
|
||||
try {
|
||||
const res = await apiFetch('/api/monitoring/logs');
|
||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
||||
|
||||
allMonitoringEvents = await res.json();
|
||||
renderTable();
|
||||
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" style="color: red;">Ошибка загрузки логов.</td></tr>';
|
||||
console.error('Error loading monitoring logs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const level = levelFilter.value;
|
||||
const source = sourceFilter.value;
|
||||
|
||||
const filteredEvents = allMonitoringEvents.filter(event => {
|
||||
const levelMatch = (level === 'all') || (event.level === level);
|
||||
const sourceMatch = (source === 'all') || (event.source === source);
|
||||
return levelMatch && sourceMatch;
|
||||
});
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
if (filteredEvents.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4">Нет событий для отображения.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredEvents.forEach(event => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `log-level-${event.level.toLowerCase()}`;
|
||||
tr.innerHTML = `
|
||||
<td>${new Date(event.timestamp).toLocaleString()}</td>
|
||||
<td><span class="log-level-label">${event.level}</span></td>
|
||||
<td>${event.source}</td>
|
||||
<td>${event.message}</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
levelFilter.addEventListener('change', renderTable);
|
||||
sourceFilter.addEventListener('change', renderTable);
|
||||
refreshBtn.addEventListener('click', loadMonitoringLogs);
|
||||
|
||||
loadMonitoringLogs();
|
||||
}
|
||||
|
||||
|
||||
// --- НАСТРОЙКИ: инициализация вкладки ---
|
||||
let settingsInitialized = false;
|
||||
|
||||
async function initSettingsTab() {
|
||||
if(settingsInitialized) return;
|
||||
settingsInitialized = true;
|
||||
|
||||
initServerParamsSection();
|
||||
initDbSection();
|
||||
initCorsSection();
|
||||
initModersSection();
|
||||
|
||||
settingsTab.addEventListener('click', (e) => {
|
||||
if (e.target.dataset.action === 'edit-pass') {
|
||||
const targetId = e.target.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
input.readOnly = false;
|
||||
input.value = '';
|
||||
input.placeholder = 'Введите новый пароль...';
|
||||
input.focus();
|
||||
e.target.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initServerParamsSection() {
|
||||
// Загрузка текущих значений
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
apiFetch('/api/settings/admin-server'),
|
||||
apiFetch('/api/settings/backend-server')
|
||||
]);
|
||||
|
||||
const adminResult = results[0];
|
||||
if (adminResult.status === 'fulfilled') {
|
||||
const adminData = await adminResult.value.json();
|
||||
document.getElementById('admin-port').value = adminData.PORT || '';
|
||||
document.getElementById('admin-url').value = adminData.ADMIN_URL || '';
|
||||
} else {
|
||||
console.error('Failed to load admin-server settings:', adminResult.reason);
|
||||
}
|
||||
|
||||
const backendResult = results[1];
|
||||
if (backendResult.status === 'fulfilled') {
|
||||
const backendData = await backendResult.value.json();
|
||||
document.getElementById('backend-host').value = backendData.host || '';
|
||||
document.getElementById('backend-port').value = backendData.port || '';
|
||||
} else {
|
||||
console.error('Failed to load backend-server settings:', backendResult.reason);
|
||||
document.getElementById('server-params-status').textContent += ' Ошибка загрузки параметров Backend.';
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('server-params-status').textContent = 'Критическая ошибка загрузки параметров';
|
||||
document.getElementById('server-params-status').style.color = 'red';
|
||||
}
|
||||
// Обработка сохранения
|
||||
document.getElementById('server-params-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const PORT = document.getElementById('admin-port').value;
|
||||
const ADMIN_URL = document.getElementById('admin-url').value;
|
||||
const host = document.getElementById('backend-host').value;
|
||||
const port = document.getElementById('backend-port').value;
|
||||
const statusEl = document.getElementById('server-params-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const [adminRes, backendRes] = await Promise.all([
|
||||
apiFetch('/api/settings/admin-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ PORT, ADMIN_URL })
|
||||
}),
|
||||
apiFetch('/api/settings/backend-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, port })
|
||||
})
|
||||
]);
|
||||
if (adminRes.ok && backendRes.ok) {
|
||||
statusEl.innerHTML = 'Параметры успешно сохранены.<br><strong class="warning-text">Требуется перезапуск обоих серверов для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Параметры успешно сохранены', 'success');
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDbSection() {
|
||||
// --- БД ---
|
||||
// MS SQL
|
||||
async function loadMssql() {
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mssql');
|
||||
const data = await res.json();
|
||||
document.getElementById('mssql-user').value = data.DB_USER || '';
|
||||
|
||||
const passWrapper = document.getElementById('mssql-pass').closest('.password-wrapper');
|
||||
passWrapper.innerHTML = `
|
||||
<input type="password" id="mssql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mssql-pass">Изменить</button>
|
||||
`;
|
||||
if (data.DB_PASSWORD) {
|
||||
passWrapper.querySelector('input').placeholder = '********';
|
||||
}
|
||||
|
||||
document.getElementById('mssql-server').value = data.DB_SERVER || '';
|
||||
document.getElementById('mssql-db').value = data.DB_NAME || '';
|
||||
} catch (e) {
|
||||
document.getElementById('mssql-status').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('mssql-status').style.color = 'red';
|
||||
}
|
||||
}
|
||||
loadMssql();
|
||||
document.getElementById('mssql-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
DB_USER: document.getElementById('mssql-user').value,
|
||||
DB_SERVER: document.getElementById('mssql-server').value,
|
||||
DB_NAME: document.getElementById('mssql-db').value
|
||||
};
|
||||
const passInput = document.getElementById('mssql-pass');
|
||||
if (!passInput.readOnly) {
|
||||
payload.DB_PASSWORD = passInput.value;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('mssql-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mssql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.innerHTML = 'Сохранено.<br><strong class="warning-text">Требуется перезапуск Admin-server для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Сохранено', 'success');
|
||||
loadMssql(); // Перезагружаем секцию, чтобы сбросить поле пароля
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
// MySQL
|
||||
async function loadMysql() {
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql');
|
||||
const data = await res.json();
|
||||
document.getElementById('mysql-host').value = data.MYSQL_HOST || '';
|
||||
document.getElementById('mysql-port').value = data.MYSQL_PORT || '';
|
||||
document.getElementById('mysql-user').value = data.MYSQL_USER || '';
|
||||
|
||||
const passWrapper = document.getElementById('mysql-pass').closest('.password-wrapper');
|
||||
passWrapper.innerHTML = `
|
||||
<input type="password" id="mysql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mysql-pass">Изменить</button>
|
||||
`;
|
||||
if (data.MYSQL_PASSWORD) {
|
||||
passWrapper.querySelector('input').placeholder = '********';
|
||||
}
|
||||
|
||||
document.getElementById('mysql-db').value = data.MYSQL_DATABASE || '';
|
||||
} catch (e) {
|
||||
document.getElementById('mysql-status').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('mysql-status').style.color = 'red';
|
||||
}
|
||||
}
|
||||
loadMysql();
|
||||
document.getElementById('mysql-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
MYSQL_HOST: document.getElementById('mysql-host').value,
|
||||
MYSQL_PORT: document.getElementById('mysql-port').value,
|
||||
MYSQL_USER: document.getElementById('mysql-user').value,
|
||||
MYSQL_DATABASE: document.getElementById('mysql-db').value
|
||||
};
|
||||
const passInput = document.getElementById('mysql-pass');
|
||||
if (!passInput.readOnly) {
|
||||
payload.MYSQL_PASSWORD = passInput.value;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('mysql-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.innerHTML = 'Сохранено.<br><strong class="warning-text">Требуется перезапуск Admin-server для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Сохранено', 'success');
|
||||
loadMysql(); // Перезагружаем секцию, чтобы сбросить поле пароля
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initCorsSection() {
|
||||
// --- CORS ---
|
||||
let allowedOrigins = [];
|
||||
async function loadCors() {
|
||||
const list = document.getElementById('cors-list');
|
||||
list.innerHTML = '<li>Загрузка...</li>';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors');
|
||||
const data = await res.json();
|
||||
allowedOrigins = Array.isArray(data.ALLOWED_ORIGINS) ? data.ALLOWED_ORIGINS : [];
|
||||
renderCorsList();
|
||||
} catch (e) {
|
||||
list.innerHTML = '<li>Ошибка загрузки</li>';
|
||||
}
|
||||
}
|
||||
const corsInput = document.getElementById('cors-new-origin');
|
||||
const corsAddBtn = document.getElementById('cors-add-btn');
|
||||
corsInput.addEventListener('input', () => {
|
||||
const val = corsInput.value.trim();
|
||||
corsAddBtn.disabled = !val || allowedOrigins.includes(val);
|
||||
});
|
||||
function renderCorsList() {
|
||||
const list = document.getElementById('cors-list');
|
||||
list.innerHTML = '';
|
||||
if (!allowedOrigins.length) {
|
||||
list.innerHTML = '<li>Нет origin</li>';
|
||||
return;
|
||||
}
|
||||
allowedOrigins.forEach((origin, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = origin + ' ';
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = 'Удалить';
|
||||
delBtn.className = 'btn-small';
|
||||
delBtn.addEventListener('click', () => {
|
||||
allowedOrigins.splice(idx, 1);
|
||||
renderCorsList();
|
||||
corsAddBtn.disabled = !corsInput.value.trim() || allowedOrigins.includes(corsInput.value.trim());
|
||||
});
|
||||
li.appendChild(delBtn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
corsAddBtn.disabled = !corsInput.value.trim() || allowedOrigins.includes(corsInput.value.trim());
|
||||
}
|
||||
loadCors();
|
||||
document.getElementById('cors-add-btn').addEventListener('click', () => {
|
||||
const input = document.getElementById('cors-new-origin');
|
||||
const val = input.value.trim();
|
||||
if (val && !allowedOrigins.includes(val)) {
|
||||
allowedOrigins.push(val);
|
||||
input.value = '';
|
||||
renderCorsList();
|
||||
corsAddBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
document.getElementById('cors-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const statusEl = document.getElementById('cors-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ALLOWED_ORIGINS: allowedOrigins })
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.innerHTML = 'Сохранено.<br><strong class="warning-text">Требуется перезапуск Admin-server для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Сохранено', 'success');
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initModersSection() {
|
||||
// --- МОДЕРАТОРЫ ---
|
||||
async function loadModers() {
|
||||
const tableBody = document.querySelector('#moders-table tbody');
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Загрузка...</td></tr>';
|
||||
try {
|
||||
const res = await apiFetch('/api/moders');
|
||||
const moders = await res.json();
|
||||
if (!Array.isArray(moders) || !moders.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Нет модераторов</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = '';
|
||||
for (const moder of moders) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = moder.id;
|
||||
tr.innerHTML = `<td>${moder.id}</td><td>${moder.username}</td>` +
|
||||
`<td>` +
|
||||
`<button class="btn-small" data-action="reset" title="Сменить пароль">🔑</button> ` +
|
||||
`<button class="btn-small" data-action="delete" title="Удалить">🗑️</button>` +
|
||||
`</td>`;
|
||||
tableBody.appendChild(tr);
|
||||
}
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
loadModers();
|
||||
// Добавление модератора
|
||||
document.getElementById('add-moder-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('add-moder-username').value.trim();
|
||||
const password = document.getElementById('add-moder-password').value;
|
||||
const statusEl = document.getElementById('add-moder-status');
|
||||
statusEl.textContent = '';
|
||||
if (!username || !password) {
|
||||
statusEl.textContent = 'Заполните все поля';
|
||||
statusEl.style.color = 'red';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/moders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.textContent = 'Модератор добавлен';
|
||||
statusEl.style.color = 'green';
|
||||
document.getElementById('add-moder-form').reset();
|
||||
loadModers();
|
||||
showToast('Модератор добавлен', 'success');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.message || 'Ошибка';
|
||||
statusEl.style.color = 'red';
|
||||
showToast(data.message || 'Ошибка', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка', 'error');
|
||||
}
|
||||
});
|
||||
// Действия: удалить/сменить пароль
|
||||
document.getElementById('moders-table').addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const id = btn.closest('tr').dataset.id;
|
||||
const action = btn.getAttribute('data-action');
|
||||
if (action === 'delete') {
|
||||
showConfirm('Удалить модератора?', async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadModers();
|
||||
showToast('Модератор удален', 'success');
|
||||
} else {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
});
|
||||
} else if (action === 'reset') {
|
||||
const row = btn.closest('tr');
|
||||
const actionsCell = row.cells[2];
|
||||
|
||||
// Если мы уже в режиме редактирования, ничего не делаем
|
||||
if (actionsCell.querySelector('input')) return;
|
||||
|
||||
// Сохраняем оригинальные кнопки
|
||||
const originalButtons = actionsCell.innerHTML;
|
||||
|
||||
// Создаем поле ввода и кнопки
|
||||
actionsCell.innerHTML = `
|
||||
<input type="password" placeholder="Новый пароль" class="moder-pass-input">
|
||||
<button class="btn-small" data-action="save-pass" title="Сохранить">✅</button>
|
||||
<button class="btn-small" data-action="cancel-pass" title="Отмена">❌</button>
|
||||
`;
|
||||
|
||||
const input = actionsCell.querySelector('input');
|
||||
input.focus();
|
||||
|
||||
const saveBtn = actionsCell.querySelector('[data-action="save-pass"]');
|
||||
const cancelBtn = actionsCell.querySelector('[data-action="cancel-pass"]');
|
||||
|
||||
const handleSave = async () => {
|
||||
const newPass = input.value;
|
||||
if (!newPass || newPass.length < 4) {
|
||||
showToast('Пароль должен быть не менее 4 символов', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: newPass })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Пароль обновлён', 'success');
|
||||
actionsCell.innerHTML = originalButtons; // Возвращаем кнопки
|
||||
} else {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
actionsCell.innerHTML = originalButtons;
|
||||
};
|
||||
|
||||
saveBtn.addEventListener('click', handleSave);
|
||||
cancelBtn.addEventListener('click', handleCancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Модератор: наполнение вкладок</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link href="/libs/quill/quill.snow.css" rel="stylesheet">
|
||||
<script src="/libs/quill/quill.js"></script>
|
||||
<script src="/libs/dompurify/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="notify"></div>
|
||||
<div class="container">
|
||||
<h1>Панель модератора</h1>
|
||||
<div id="login-section">
|
||||
<h2>Вход для модератора</h2>
|
||||
<form id="moder-login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Логин:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
<p id="login-error" style="color: red;"></p>
|
||||
</form>
|
||||
</div>
|
||||
<div id="editor-section" style="display:none;">
|
||||
<button id="logout-btn" style="float:right;">Выйти из системы</button>
|
||||
<h2>Редактирование вкладок</h2>
|
||||
<div class="tab-controls">
|
||||
<button class="tab-btn active" data-tab="info">Общая информация</button>
|
||||
<button class="tab-btn" data-tab="union">Профсоюз</button>
|
||||
</div>
|
||||
<div style="margin: 18px 0;">
|
||||
<button id="add-block-btn">Добавить блок</button>
|
||||
</div>
|
||||
<div id="blocks-list"></div>
|
||||
</div>
|
||||
<div id="edit-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span class="close" id="edit-modal-close">×</span>
|
||||
<h3 id="edit-modal-title">Блок</h3>
|
||||
<form id="edit-block-form">
|
||||
<input type="hidden" id="edit-block-id">
|
||||
<div class="form-group">
|
||||
<label for="edit-quill-editor">Холст:</label>
|
||||
<div id="edit-quill-editor" style="height: 250px;"></div>
|
||||
</div>
|
||||
<div id="edit-preview-label" style="margin-top:10px;font-weight:bold;">Предпросмотр:</div>
|
||||
<div id="edit-block-preview" class="block-text" style="min-height:40px;margin-bottom:10px;"></div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="confirm-modal" class="modal" style="display:none; z-index:2000;">
|
||||
<div class="modal-content" style="max-width:400px; text-align:center;">
|
||||
<p style="margin-bottom:20px; ">Вы уверены, что хотите закрыть окно?<br>Несохранённые изменения будут потеряны!</p>
|
||||
<button id="confirm-close-yes" style="background:#e74c3c; margin-right:12px;">Да, закрыть</button>
|
||||
<button id="confirm-close-no">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,209 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const editorSection = document.getElementById('editor-section');
|
||||
const loginForm = document.getElementById('moder-login-form');
|
||||
const loginError = document.getElementById('login-error');
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
const addBlockBtn = document.getElementById('add-block-btn');
|
||||
const blocksList = document.getElementById('blocks-list');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
loginError.textContent = '';
|
||||
const username = loginForm.username.value.trim();
|
||||
const password = loginForm.password.value;
|
||||
try {
|
||||
const res = await fetch('/moders/moder-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
loginSection.style.display = 'none';
|
||||
editorSection.style.display = '';
|
||||
loadBlocks(currentTab);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
loginError.textContent = data.message || 'Ошибка входа';
|
||||
}
|
||||
} catch (err) {
|
||||
loginError.textContent = 'Ошибка сервера';
|
||||
}
|
||||
});
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = async () => {
|
||||
await fetch('/moders/moder-logout');
|
||||
editorSection.style.display = 'none';
|
||||
loginSection.style.display = '';
|
||||
showNotify('Вы вышли из системы', 'success');
|
||||
};
|
||||
}
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentTab = btn.dataset.tab;
|
||||
loadBlocks(currentTab);
|
||||
});
|
||||
});
|
||||
function loadBlocks(tab) {
|
||||
fetch(`/moders/content/${tab}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
blocksList.innerHTML = '';
|
||||
if (!Array.isArray(data) || !data.length) {
|
||||
blocksList.innerHTML = '<p>Нет блоков</p>';
|
||||
return;
|
||||
}
|
||||
data.forEach(block => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'content-block';
|
||||
div.innerHTML = `<div class="block-preview" style="margin-bottom:10px;">${DOMPurify.sanitize(block.content)}</div>`;
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.textContent = 'Редактировать';
|
||||
editBtn.onclick = () => openEditModal(block);
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = 'Удалить';
|
||||
delBtn.onclick = async () => {
|
||||
if (confirm('Удалить блок?')) {
|
||||
await fetch(`/moders/content/${tab}/${block.id}`, { method: 'DELETE' });
|
||||
showNotify('Блок удалён', 'success');
|
||||
loadBlocks(tab);
|
||||
}
|
||||
};
|
||||
div.appendChild(editBtn);
|
||||
div.appendChild(delBtn);
|
||||
blocksList.appendChild(div);
|
||||
});
|
||||
});
|
||||
}
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const editModalClose = document.getElementById('edit-modal-close');
|
||||
const editBlockForm = document.getElementById('edit-block-form');
|
||||
const editBlockId = document.getElementById('edit-block-id');
|
||||
const editModalTitle = document.getElementById('edit-modal-title');
|
||||
let editQuill;
|
||||
let currentTab = 'info';
|
||||
let editingBlockId = null;
|
||||
let originalContent = '';
|
||||
function showNotify(msg, type = 'success') {
|
||||
const notify = document.getElementById('notify');
|
||||
notify.textContent = msg;
|
||||
notify.className = 'show ' + type;
|
||||
setTimeout(() => { notify.className = ''; }, 2200);
|
||||
}
|
||||
|
||||
if (typeof Quill === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
console.error('Required libraries not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Инициализируйте Quill
|
||||
if (!initQuill()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
function initQuill() {
|
||||
if (typeof Quill === 'undefined') {
|
||||
console.error('Quill not loaded');
|
||||
return false;
|
||||
}
|
||||
editQuill = new Quill('#edit-quill-editor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'align': [] }],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['blockquote', 'code-block'],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function openEditModal(block) {
|
||||
editingBlockId = block ? block.id : null;
|
||||
editBlockId.value = block ? block.id : '';
|
||||
editModalTitle.textContent = block ? 'Редактировать блок' : 'Добавить блок';
|
||||
editQuill.root.innerHTML = block ? block.content : '';
|
||||
document.getElementById('edit-block-preview').innerHTML = block ? DOMPurify.sanitize(block.content) : '';
|
||||
originalContent = block ? block.content : '';
|
||||
editModal.style.display = 'block';
|
||||
}
|
||||
editQuill.on('text-change', () => {
|
||||
document.getElementById('edit-block-preview').innerHTML = DOMPurify.sanitize(editQuill.root.innerHTML);
|
||||
});
|
||||
function tryCloseEditModal() {
|
||||
const currentContent = editQuill.root.innerHTML;
|
||||
if (currentContent !== originalContent) {
|
||||
document.getElementById('confirm-modal').style.display = 'flex';
|
||||
const yesBtn = document.getElementById('confirm-close-yes');
|
||||
const noBtn = document.getElementById('confirm-close-no');
|
||||
yesBtn.onclick = function() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
editModal.style.display = 'none';
|
||||
};
|
||||
noBtn.onclick = function() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
};
|
||||
return;
|
||||
}
|
||||
editModal.style.display = 'none';
|
||||
}
|
||||
editModalClose.onclick = tryCloseEditModal;
|
||||
window.onclick = (event) => {
|
||||
if (event.target === editModal) tryCloseEditModal();
|
||||
};
|
||||
addBlockBtn.onclick = () => openEditModal(null);
|
||||
editBlockForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = DOMPurify.sanitize(editQuill.root.innerHTML.trim());
|
||||
if (!content || content === '<p><br></p>') return;
|
||||
if (editingBlockId) {
|
||||
await fetch(`/moders/content/${currentTab}/${editingBlockId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
showNotify('Блок обновлён', 'success');
|
||||
} else {
|
||||
await fetch(`/moders/content/${currentTab}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
showNotify('Блок добавлен', 'success');
|
||||
}
|
||||
editModal.style.display = 'none';
|
||||
loadBlocks(currentTab);
|
||||
});
|
||||
fetch('/moders/check-session', { credentials: 'include' }).then(res => {
|
||||
if (res.ok) {
|
||||
loginSection.style.display = 'none';
|
||||
editorSection.style.display = '';
|
||||
loadBlocks(currentTab);
|
||||
} else {
|
||||
loginSection.style.display = '';
|
||||
editorSection.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
let csrfToken = '';
|
||||
fetch('/moders/csrf-token').then(res => res.json()).then(data => {
|
||||
csrfToken = data.csrfToken;
|
||||
});
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
if (options && options.method && ['POST','PUT','DELETE'].includes(options.method.toUpperCase())) {
|
||||
options.headers = options.headers || {};
|
||||
options.headers['x-csrf-token'] = csrfToken;
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Панель администратора</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container"></div>
|
||||
<div class="container">
|
||||
<h1>Панель администратора</h1>
|
||||
<div class="tabs">
|
||||
<button id="tab-main" class="tab-btn active">Общее</button>
|
||||
<button id="tab-settings" class="tab-btn">Настройки</button>
|
||||
<button id="tab-monitoring" class="tab-btn">Мониторинг</button>
|
||||
</div>
|
||||
<div id="main-tab">
|
||||
<div id="status">
|
||||
<p>Статус сервера: <span id="server-status">загрузка...</span></p>
|
||||
<button id="refresh-status-btn" class="btn-small">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="db-status-container">
|
||||
<div>
|
||||
<p>Админ -> БД: <span id="admin-db-status">неизвестно</span></p>
|
||||
<small id="admin-db-message"></small>
|
||||
</div>
|
||||
<div>
|
||||
<p>Программа -> БД: <span id="backend-db-status">неизвестно</span></p>
|
||||
<small id="backend-db-message"></small>
|
||||
</div>
|
||||
<button id="check-db-btn" class="btn-small">Проверить</button>
|
||||
</div>
|
||||
|
||||
<div class="network-diag-container">
|
||||
<h2>Сетевая диагностика (Ping)</h2>
|
||||
<div class="ping-controls">
|
||||
<input type="text" id="ping-ip" placeholder="Введите IP-адрес или хост">
|
||||
<button id="ping-btn" class="btn-small">Пинг</button>
|
||||
<button id="ping-stop-btn" class="btn-small" disabled>Стоп</button>
|
||||
<button id="ping-clear-btn" class="btn-small">Очистить</button>
|
||||
</div>
|
||||
<pre id="ping-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<h2>Логи приложения</h2>
|
||||
<div class="log-controls">
|
||||
<button id="refresh-logs-btn" class="btn-small">Обновить логи</button>
|
||||
<button id="clear-logs-btn" class="btn-small">Очистить</button>
|
||||
</div>
|
||||
<pre id="logs-content">Нажмите "Обновить логи" для просмотра</pre>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<h2>Логи считывателя</h2>
|
||||
<div class="log-controls">
|
||||
<button id="refresh-card-logs-btn" class="btn-small">Обновить логи считывателя</button>
|
||||
</div>
|
||||
<pre id="ping-output">
|
||||
<pre id="card-logs-content">Нажмите "Обновить логи считывателя" для просмотра</pre>
|
||||
</pre></pre>
|
||||
</div>
|
||||
|
||||
<div class="config-container">
|
||||
<h2>Конфигурация</h2>
|
||||
<form id="config-form">
|
||||
<div class="form-group">
|
||||
<label for="payslipsPath">Путь к расчетным листкам:</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="payslipsPath" name="payslipsPath" required>
|
||||
<button type="button" id="browse-btn">Обзор...</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p id="config-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-tab" style="display:none">
|
||||
<h2>Настройки</h2>
|
||||
<div id="server-params-section"><h3>Параметры серверов</h3>
|
||||
<form id="server-params-form" novalidate>
|
||||
<fieldset><legend>Admin-server</legend>
|
||||
<label>PORT: <input type="number" id="admin-port" min="1" max="65535" required></label><br>
|
||||
<label>ADMIN_URL: <input type="text" id="admin-url" pattern="https?://.+" required></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Backend</legend>
|
||||
<label>host: <input type="text" id="backend-host" required></label><br>
|
||||
<label>port: <input type="number" id="backend-port" min="1" max="65535" required></label>
|
||||
</fieldset>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="server-params-status" class="form-error"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="db-section"><h3>Подключения к БД</h3>
|
||||
<form id="mssql-form" novalidate><fieldset><legend>MS SQL</legend>
|
||||
<label>DB_USER: <input type="text" id="mssql-user" required></label><br>
|
||||
<label>DB_PASSWORD:
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="mssql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mssql-pass">Изменить</button>
|
||||
</div>
|
||||
</label><br>
|
||||
<label>DB_SERVER: <input type="text" id="mssql-server" required></label><br>
|
||||
<label>DB_NAME: <input type="text" id="mssql-db" required></label><br>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="mssql-status" class="form-error"></span>
|
||||
</fieldset></form>
|
||||
<form id="mysql-form" novalidate><fieldset><legend>MySQL (модераторы)</legend>
|
||||
<label>MYSQL_HOST: <input type="text" id="mysql-host" required></label><br>
|
||||
<label>MYSQL_PORT: <input type="number" id="mysql-port" min="1" max="65535" required></label><br>
|
||||
<label>MYSQL_USER: <input type="text" id="mysql-user" required></label><br>
|
||||
<label>MYSQL_PASSWORD:
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="mysql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mysql-pass">Изменить</button>
|
||||
</div>
|
||||
</label><br>
|
||||
<label>MYSQL_DATABASE: <input type="text" id="mysql-db" required></label><br>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="mysql-status" class="form-error"></span>
|
||||
</fieldset></form>
|
||||
</div>
|
||||
<div id="cors-section"><h3>CORS (ALLOWED_ORIGINS)</h3>
|
||||
<form id="cors-form">
|
||||
<ul id="cors-list"></ul>
|
||||
<input type="text" id="cors-new-origin" placeholder="Добавить origin...">
|
||||
<button type="button" id="cors-add-btn" disabled>Добавить</button>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="cors-status" class="form-error"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="moders-section"><h3>Модераторы</h3>
|
||||
<form id="add-moder-form" novalidate>
|
||||
<input type="text" id="add-moder-username" placeholder="Логин" required>
|
||||
<input type="password" id="add-moder-password" placeholder="Пароль (мин. 4 символа)" minlength="4" required>
|
||||
<button type="submit">Добавить</button>
|
||||
<span id="add-moder-status" class="form-error"></span>
|
||||
</form>
|
||||
<table id="moders-table" class="moders-table"><thead><tr><th>ID</th><th>Логин</th><th>Действия</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoring-tab" style="display:none;">
|
||||
<h2>Мониторинг системы</h2>
|
||||
<div id="monitoring-status-container">
|
||||
<!-- Статусы будут здесь -->
|
||||
</div>
|
||||
|
||||
<h3>Проблемные события (Warn/Error)</h3>
|
||||
<div class="monitoring-filters">
|
||||
<label>Уровень:
|
||||
<select id="log-level-filter">
|
||||
<option value="all">Все</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Источник:
|
||||
<select id="log-source-filter">
|
||||
<option value="all">Все</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Backend">Backend</option>
|
||||
<option value="CardReader">CardReader</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="refresh-monitoring-btn" class="btn-small">Обновить</button>
|
||||
</div>
|
||||
<table id="monitoring-log-table" class="moders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">Время</th>
|
||||
<th style="width: 10%;">Уровень</th>
|
||||
<th style="width: 15%;">Источник</th>
|
||||
<th>Сообщение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Данные будут здесь -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для подтверждений -->
|
||||
<div id="confirm-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<p id="confirm-modal-text">Вы уверены?</p>
|
||||
<div class="modal-buttons">
|
||||
<button id="confirm-modal-yes" class="btn-danger">Да</button>
|
||||
<button id="confirm-modal-no" class="btn-secondary">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Вход в панель администратора</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Вход</h1>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<p id="error-message" style="color: red;"></p>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
document.getElementById('error-message').textContent = 'Неверный пароль';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
router.post('/', authController.login);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cardController = require('../controllers/cardController');
|
||||
|
||||
router.post('/card-event', cardController.cardEvent);
|
||||
router.get('/card-logs', cardController.getCardLogs);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const healthController = require('../controllers/healthController');
|
||||
|
||||
router.get('/check-db', healthController.checkDb);
|
||||
router.get('/status', healthController.checkStatus);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router(); // Сначала инициализируем router
|
||||
const logsController = require('../controllers/logsController');
|
||||
const checkAuth = require('../middleware/checkAuth');
|
||||
console.log('Imported router:', router.toString());
|
||||
|
||||
router.get('/', checkAuth, logsController.getLogs);
|
||||
router.post('/clear', checkAuth, logsController.clearLogs);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const modersController = require('../controllers/modersController');
|
||||
const checkAuth = require('../middleware/checkAuth');
|
||||
|
||||
router.get('/', checkAuth, modersController.getModers);
|
||||
router.post('/', checkAuth, modersController.addModer);
|
||||
router.put('/:id', checkAuth, modersController.changePassword);
|
||||
router.delete('/:id', checkAuth, modersController.deleteModer);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logsController = require('../controllers/logsController');
|
||||
const checkAuth = require('../middleware/checkAuth');
|
||||
|
||||
router.get('/logs', checkAuth, logsController.getMonitoringLogs);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const settingsController = require('../controllers/settingsController');
|
||||
const checkAuth = require('../middleware/checkAuth');
|
||||
|
||||
router.get('/mysql', checkAuth, settingsController.getMysqlSettings);
|
||||
router.post('/mysql', checkAuth, settingsController.updateMysqlSettings);
|
||||
|
||||
router.get('/cors', checkAuth, settingsController.getCorsSettings);
|
||||
router.post('/cors', checkAuth, settingsController.updateCorsSettings);
|
||||
|
||||
router.get('/admin-server', checkAuth, settingsController.getAdminServerSettings);
|
||||
router.post('/admin-server', checkAuth, settingsController.updateAdminServerSettings);
|
||||
|
||||
router.get('/backend-server', checkAuth, settingsController.getBackendServerSettings);
|
||||
router.post('/backend-server', checkAuth, settingsController.updateBackendServerSettings);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,114 @@
|
||||
const fs = require('fs-extra');
|
||||
const cors = require('cors');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const session = require('express-session');
|
||||
require('dotenv').config({ path: path.join(__dirname, './config.env') });
|
||||
require('dotenv').config({ path: __dirname + '/moders/config.env' });
|
||||
const logger = require('./logger');
|
||||
const moderAuthRouter = require('./moders/routes/moderAuth');
|
||||
const moderContentRouter = require('./moders/routes/content');
|
||||
const authRouter = require('./routes/auth');
|
||||
const cardRouter = require('./routes/card');
|
||||
const logsRouter = require('./routes/logs');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const modersRouter = require('./routes/moders');
|
||||
const healthRouter = require('./routes/health');
|
||||
const monitoringRouter = require('./routes/monitoring');
|
||||
const setupWebSocket = require('./services/websocket');
|
||||
const cardController = require('./controllers/cardController');
|
||||
const app = express();
|
||||
const server = require('http').createServer(app);
|
||||
|
||||
// Конфигурации
|
||||
const corsConfig = require('./config/cors');
|
||||
const sessionConfig = require('./config/session');
|
||||
app.use('/libs/quill', express.static(path.join(__dirname, 'node_modules/quill/dist')));
|
||||
app.use('/libs/dompurify', express.static(path.join(__dirname, 'node_modules/dompurify/dist')));
|
||||
// Middleware
|
||||
app.use(cors(corsConfig));
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(session(sessionConfig(session)));
|
||||
// Роуты, которые не требуют CSRF
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use(require('./middleware/csrf'));
|
||||
// Статические файлы
|
||||
app.use('/login.html', express.static(path.join(__dirname, 'public', 'login.html')));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/uploads', express.static(__dirname + '/uploads'));
|
||||
// Роуты
|
||||
app.use('/api/card', cardRouter);
|
||||
app.use('/api/logs', logsRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
app.use('/api/moders', modersRouter);
|
||||
app.use('/api/health', healthRouter);
|
||||
app.use('/api/monitoring', monitoringRouter);
|
||||
app.use('/moders', moderAuthRouter);
|
||||
app.use('/moders/content', moderContentRouter);
|
||||
// Initialize card logs
|
||||
(async () => {
|
||||
try {
|
||||
await cardController.initCardLogs();
|
||||
logger.info('Card logs initialized successfully');
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize card logs:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
// WebSocket
|
||||
setupWebSocket(server);
|
||||
|
||||
// Обработчик ошибок (должен быть последним!)
|
||||
app.use(require('./middleware/errorHandler'));
|
||||
app.get('/api/status', async (req, res) => {
|
||||
try {
|
||||
res.json({ status: 'online' }); // или другая логика проверки
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config', async (req, res) => {
|
||||
try {
|
||||
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
||||
const backendConfig = await fs.readJson(backendConfigPath);
|
||||
const config = {
|
||||
payslipsPath: backendConfig.payslipsPath
|
||||
};
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
logger.error('Ошибка чтения backend/config.json:', err);
|
||||
res.status(500).json({ error: 'Ошибка загрузки конфигурации' });
|
||||
}
|
||||
});
|
||||
// Добавьте рядом с существующим GET обработчиком
|
||||
app.post('/api/config', async (req, res) => {
|
||||
try {
|
||||
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
||||
const currentConfig = await fs.readJson(backendConfigPath);
|
||||
|
||||
// Обновляем только разрешенные поля
|
||||
if (req.body.payslipsPath) {
|
||||
currentConfig.payslipsPath = req.body.payslipsPath;
|
||||
}
|
||||
|
||||
// Сохраняем обновленный конфиг
|
||||
await fs.writeJson(backendConfigPath, currentConfig, { spaces: 4 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Конфигурация успешно обновлена'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Ошибка сохранения конфигурации:', err);
|
||||
res.status(500).json({
|
||||
error: 'Ошибка сохранения конфигурации',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
server.listen(process.env.PORT || 3005, () => {
|
||||
logger.info(`Server running on ${process.env.ADMIN_URL}`);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const WebSocket = require('ws');
|
||||
const iconv = require('iconv-lite');
|
||||
const { spawn } = require('child_process');
|
||||
const logger = require('../logger');
|
||||
|
||||
function setupWebSocket(server) {
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
logger.info('New WebSocket connection');
|
||||
|
||||
let pingProcess = null;
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
if (data.type === 'ping') {
|
||||
const ip = data.ip;
|
||||
|
||||
// Валидация IP
|
||||
if (!ip || !/^[a-zA-Z0-9\.\-_:]+$/.test(ip)) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid IP address format'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Завершаем предыдущий процесс, если есть
|
||||
if (pingProcess) {
|
||||
pingProcess.kill();
|
||||
}
|
||||
|
||||
// Запускаем ping (для Windows)
|
||||
pingProcess = spawn('ping', ['-t', ip]);
|
||||
|
||||
// Обработка вывода
|
||||
pingProcess.stdout.on('data', (data) => {
|
||||
const decodedData = iconv.decode(data, 'cp866');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'data',
|
||||
data: decodedData
|
||||
}));
|
||||
});
|
||||
|
||||
pingProcess.stderr.on('data', (data) => {
|
||||
const decodedData = iconv.decode(data, 'cp866');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
data: decodedData
|
||||
}));
|
||||
});
|
||||
|
||||
pingProcess.on('close', (code) => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'close',
|
||||
data: `Process exited with code ${code}`
|
||||
}));
|
||||
pingProcess = null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('WebSocket message error:', e);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid message format',
|
||||
details: e.message
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.info('WebSocket connection closed');
|
||||
if (pingProcess) {
|
||||
pingProcess.kill();
|
||||
pingProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
if (pingProcess) {
|
||||
pingProcess.kill();
|
||||
pingProcess = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = setupWebSocket;
|
||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -12,6 +12,6 @@ LOGS_DIR=../backend/logs
|
||||
CARD_LOG_PATH=../backend/logs/cards.log
|
||||
ADMIN_LOG=./logs/admin.log
|
||||
ADMIN_LOG_DIR=../logs
|
||||
ALLOWED_ORIGINS=http://localhost:3000, http://localhost:3005, http://localhost:3001, http://127.0.0.1:3000, http://localhost:3002
|
||||
ALLOWED_ORIGINS=http://localhost:3000, http://localhost:3005, http://localhost:3001, http://127.0.0.1:3000
|
||||
BACKEND_APP_LOG=../backend/logs/app.log
|
||||
CARD_LOG=../backend/logs/cards.log
|
||||
@@ -10,5 +10,3 @@ module.exports = {
|
||||
},
|
||||
credentials: true
|
||||
};
|
||||
console.log('📋 ALLOWED_ORIGINS из .env:', process.env.ALLOWED_ORIGINS);
|
||||
console.log('📋 Массив разрешённых origins:', allowedOrigins);
|
||||
@@ -1,5 +1,5 @@
|
||||
const logger = require('../logger');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moderDb = require('../moders/db');
|
||||
require('dotenv').config({ path: __dirname + './config.env' });
|
||||
exports.login = (req, res) => {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
const fs = require('fs').promises;
|
||||
const { existsSync, appendFileSync } = require('fs');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
const CARD_LOG_PATH = path.resolve(__dirname, '..', process.env.CARD_LOG_PATH);
|
||||
const initCardLogs = async () => {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(CARD_LOG_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.access(CARD_LOG_PATH);
|
||||
} catch (e) {
|
||||
await fs.ensureDir(path.dirname(CARD_LOG_PATH));
|
||||
if (!(await fs.pathExists(CARD_LOG_PATH))) {
|
||||
await fs.writeFile(CARD_LOG_PATH, '');
|
||||
logger.info(`Card log file created at: ${CARD_LOG_PATH}`);
|
||||
}
|
||||
@@ -34,10 +31,10 @@ const cardEvent = (req, res) => {
|
||||
const logLine = `[${new Date(timestamp).toISOString()}] [${event}] UID: ${uid}\n`;
|
||||
|
||||
try {
|
||||
if (!existsSync(CARD_LOG_PATH)) {
|
||||
if (!fs.existsSync(CARD_LOG_PATH)) {
|
||||
initCardLogs();
|
||||
}
|
||||
appendFileSync(CARD_LOG_PATH, logLine);
|
||||
fs.appendFileSync(CARD_LOG_PATH, logLine);
|
||||
logger.info('[CARD EVENT] Log written to:', CARD_LOG_PATH);
|
||||
return res.status(200).json({ message: 'OK', path: CARD_LOG_PATH });
|
||||
|
||||
@@ -52,9 +49,7 @@ const cardEvent = (req, res) => {
|
||||
};
|
||||
const getCardLogs = async (req, res) => {
|
||||
try {
|
||||
try {
|
||||
await fs.access(CARD_LOG_PATH);
|
||||
} catch (e) {
|
||||
if (!(await fs.pathExists(CARD_LOG_PATH))) {
|
||||
await initCardLogs();
|
||||
return res.status(200).type('text/plain').send('Card logs initialized');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const fs = require('fs').promises;
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
@@ -40,9 +40,7 @@ const getMonitoringLogs = async (req, res) => {
|
||||
|
||||
for (const source of logSources) {
|
||||
try {
|
||||
try {
|
||||
await fs.access(source.path);
|
||||
} catch (e) {
|
||||
if (!(await fs.pathExists(source.path))) {
|
||||
logger.warn(`Log file not found: ${source.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const moderDb = require('../moders/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const bcrypt = require('bcrypt');
|
||||
const logger = require('../logger');
|
||||
|
||||
const getModers = async (req, res) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const fs = require('fs').promises;
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
@@ -131,8 +131,7 @@ const updateAdminServerSettings = async (req, res) => {
|
||||
|
||||
const getBackendServerSettings = async (req, res) => {
|
||||
try {
|
||||
const content = await fs.readFile(backendConfigPath, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
const config = await fs.readJson(backendConfigPath);
|
||||
res.json({
|
||||
host: config.server?.host || '',
|
||||
port: config.server?.port || ''
|
||||
@@ -149,12 +148,11 @@ const updateBackendServerSettings = async (req, res) => {
|
||||
return res.status(400).json({ message: 'host и port обязательны' });
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(backendConfigPath, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
const config = await fs.readJson(backendConfigPath);
|
||||
config.server = config.server || {};
|
||||
config.server.host = host;
|
||||
config.server.port = port;
|
||||
|
||||
await fs.writeFile(backendConfigPath, JSON.stringify(config, null, 4));
|
||||
await fs.writeJson(backendConfigPath, config, { spaces: 4 });
|
||||
res.json({ message: 'Параметры backend-server обновлены' });
|
||||
} catch (e) {
|
||||
logger.error('Ошибка записи backend/config.json:', e);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// middleware/csrf.js
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
const csrfMiddleware = (req, res, next) => {
|
||||
if (
|
||||
req.path === '/login' ||
|
||||
req.path === '/moders/moder-login' ||
|
||||
req.path === '/moders/csrf-token'
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return csrfProtection(req, res, next);
|
||||
};
|
||||
|
||||
module.exports = csrfMiddleware;
|
||||
@@ -9,254 +9,92 @@ const fs = require('fs');
|
||||
const uploadDir = path.join(__dirname, '../../uploads');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
|
||||
cb(null, name);
|
||||
}
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
|
||||
cb(null, name);
|
||||
}
|
||||
});
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png',
|
||||
'video/mp4', 'video/quicktime'
|
||||
'image/jpeg', 'image/png',
|
||||
'video/mp4', 'video/quicktime'
|
||||
];
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Недопустимый тип файла'));
|
||||
}
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Недопустимый тип файла'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📸 СЛАЙДЫ ДЛЯ КАРУСЕЛИ (В САМОМ НАЧАЛЕ)
|
||||
// ============================================
|
||||
|
||||
// Получить все активные слайды
|
||||
router.get('/slides', async (req, res) => {
|
||||
console.log('📸 GET /slides');
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM slides WHERE is_active = 1 ORDER BY `order` ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка получения слайдов:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить все слайды (для админки)
|
||||
router.get('/slides/all', async (req, res) => {
|
||||
console.log('📸 GET /slides/all');
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM slides ORDER BY `order` ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка получения всех слайдов:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка изображения для слайда
|
||||
router.post('/slides/upload', upload.single('image'), (req, res) => {
|
||||
console.log('📸 POST /slides/upload');
|
||||
console.log('📁 req.file:', req.file);
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Файл не загружен' });
|
||||
}
|
||||
const relPath = '/uploads/' + req.file.filename;
|
||||
console.log('✅ Изображение загружено:', relPath);
|
||||
res.json({ path: relPath });
|
||||
});
|
||||
|
||||
// Создать слайд
|
||||
router.post('/slides', async (req, res) => {
|
||||
console.log('📸 POST /slides');
|
||||
const { image_url, title, description, link, order, is_active } = req.body;
|
||||
|
||||
if (!image_url) {
|
||||
return res.status(400).json({ message: 'image_url обязателен' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO slides (image_url, title, description, link, \`order\`, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1]
|
||||
);
|
||||
console.log('✅ Слайд создан, ID:', result.insertId);
|
||||
res.json({ id: result.insertId, message: 'Слайд создан' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка создания слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить слайд
|
||||
router.put('/slides/:id', async (req, res) => {
|
||||
console.log('📸 PUT /slides/' + req.params.id);
|
||||
const { id } = req.params;
|
||||
const { image_url, title, description, link, order, is_active } = req.body;
|
||||
|
||||
if (!image_url) {
|
||||
return res.status(400).json({ message: 'image_url обязателен' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE slides
|
||||
SET image_url = ?,
|
||||
title = ?,
|
||||
description = ?,
|
||||
link = ?,
|
||||
\`order\` = ?,
|
||||
is_active = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1, id]
|
||||
);
|
||||
console.log('✅ Слайд обновлён, ID:', id);
|
||||
res.json({ message: 'Слайд обновлён' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка обновления слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить слайд
|
||||
router.delete('/slides/:id', async (req, res) => {
|
||||
console.log('📸 DELETE /slides/' + req.params.id);
|
||||
const { id } = req.params;
|
||||
try {
|
||||
await db.query('DELETE FROM slides WHERE id = ?', [id]);
|
||||
console.log('✅ Слайд удалён, ID:', id);
|
||||
res.json({ message: 'Слайд удалён' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка удаления слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📚 ОСТАЛЬНЫЕ МАРШРУТЫ (НИЖЕ)
|
||||
// ============================================
|
||||
|
||||
// Получить все блоки для вкладки
|
||||
router.get('/:tab', async (req, res) => {
|
||||
const { tab } = req.params;
|
||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
const { tab } = req.params;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка файла (image/video)
|
||||
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
|
||||
const relPath = '/uploads/' + req.file.filename;
|
||||
res.json({ path: relPath });
|
||||
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
|
||||
const relPath = '/uploads/' + req.file.filename;
|
||||
res.json({ path: relPath });
|
||||
});
|
||||
|
||||
// Добавить новый блок
|
||||
router.post('/:tab', requireModerator, async (req, res) => {
|
||||
const { tab } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||
[tab, 'text', content, req.session.userId]
|
||||
);
|
||||
res.json({ id: result.insertId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
const { tab } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||
[tab, 'text', content, req.session.userId]
|
||||
);
|
||||
res.json({ id: result.insertId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить блок
|
||||
router.put('/:tab/:id', requireModerator, async (req, res) => {
|
||||
const { tab, id } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query(
|
||||
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
|
||||
[content, id, tab]
|
||||
);
|
||||
res.json({ message: 'Обновлено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
const { tab, id } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query(
|
||||
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
|
||||
[content, id, tab]
|
||||
);
|
||||
res.json({ message: 'Обновлено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить блок
|
||||
router.delete('/:tab/:id', requireModerator, async (req, res) => {
|
||||
const { tab, id } = req.params;
|
||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
|
||||
res.json({ message: 'Удалено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/section/:section', async (req, res) => {
|
||||
const { section } = req.params;
|
||||
if (!['events', 'help', 'housing'].includes(section)) {
|
||||
return res.status(400).json({ message: 'Некорректный раздел' });
|
||||
}
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
|
||||
[section]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Сохранить контент раздела
|
||||
router.post('/section/:section', requireModerator, async (req, res) => {
|
||||
const { section } = req.params;
|
||||
const { content } = req.body;
|
||||
|
||||
if (!['events', 'help', 'housing'].includes(section)) {
|
||||
return res.status(400).json({ message: 'Некорректный раздел' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||
[section, 'text', content, req.session.userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.insertId,
|
||||
message: 'Раздел сохранён'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
const { tab, id } = req.params;
|
||||
if (!['info', 'union'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||
try {
|
||||
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
|
||||
res.json({ message: 'Удалено' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,111 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const logger = require('../../logger');
|
||||
const multer = require('multer');
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
const upload = multer({ dest: 'uploads/tmp/' });
|
||||
|
||||
// ================= ЗАКАЗЫ =================
|
||||
router.get('/orders', async (req, res) => {
|
||||
try {
|
||||
const { status, date } = req.query;
|
||||
const { data } = await axios.get(`${BACKEND_URL}/api/food/orders`, {
|
||||
params: { status, date }, timeout: 5000,
|
||||
});
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
logger.error('[admin/food/orders GET]', e.message);
|
||||
res.status(500).json({ error: 'Не удалось получить заказы' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/orders/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { data } = await axios.patch(`${BACKEND_URL}/api/food/orders/${req.params.id}/status`, req.body, { timeout: 5000 });
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
logger.error('[admin/food/orders PATCH]', e.message);
|
||||
res.status(500).json({ error: 'Ошибка обновления статуса' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= МЕНЮ =================
|
||||
router.get('/items', async (req, res) => {
|
||||
try {
|
||||
const { data } = await axios.get(`${BACKEND_URL}/api/food/menu`);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Не удалось получить меню' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/items', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Передаем текстовые поля
|
||||
Object.keys(req.body).forEach(key => {
|
||||
formData.append(key, req.body[key]);
|
||||
});
|
||||
|
||||
// Передаем файл, если он есть
|
||||
if (req.file) {
|
||||
formData.append('image', fs.createReadStream(req.file.path));
|
||||
}
|
||||
|
||||
const { data } = await axios.post(`${BACKEND_URL}/api/food/items`, formData, {
|
||||
headers: formData.getHeaders(),
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
if (req.file) fs.unlinkSync(req.file.path);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
console.error('Ошибка проксирования POST:', e.response?.data || e.message);
|
||||
res.status(500).json({ error: e.response?.data?.error || 'Ошибка добавления блюда' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/items/:id', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Передаем текстовые поля
|
||||
Object.keys(req.body).forEach(key => {
|
||||
formData.append(key, req.body[key]);
|
||||
});
|
||||
|
||||
// Передаем файл, если он есть
|
||||
if (req.file) {
|
||||
formData.append('image', fs.createReadStream(req.file.path));
|
||||
}
|
||||
|
||||
const { data } = await axios.put(`${BACKEND_URL}/api/food/items/${req.params.id}`, formData, {
|
||||
headers: formData.getHeaders(),
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
if (req.file) fs.unlinkSync(req.file.path);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
console.error('Ошибка проксирования PUT:', e.response?.data || e.message);
|
||||
res.status(500).json({ error: e.response?.data?.error || 'Ошибка обновления блюда' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/items/:id', async (req, res) => {
|
||||
try {
|
||||
const { data } = await axios.delete(`${BACKEND_URL}/api/food/items/${req.params.id}`);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Ошибка удаления блюда' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,9 +1,12 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const bcrypt = require('bcrypt');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const logger = require('../../logger');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 30 * 60 * 1000, // 30 минут
|
||||
@@ -13,6 +16,10 @@ const loginLimiter = rateLimit({
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
router.get('/csrf-token', csrfProtection, (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
});
|
||||
|
||||
router.post('/moder-login', loginLimiter, async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
logger.info(`[LOGIN] Попытка входа: ${username}`);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"start": "node server.js",
|
||||
"electron": "electron .",
|
||||
"electron-dev": "set NODE_ENV=development&& electron .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -14,20 +15,24 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"csurf": "^1.11.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"electron": "^37.0.0",
|
||||
"express": "^5.1.0",
|
||||
"express-mysql-session": "^3.0.3",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"express-session": "^1.18.1",
|
||||
"form-data": "^4.0.6",
|
||||
"helmet": "^8.3.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.1",
|
||||
"mysql2": "^3.14.2",
|
||||
"quill": "^2.0.3",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron-builder": "^26.15.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statusElement = document.getElementById('server-status');
|
||||
const refreshBtn = document.getElementById('refresh-status-btn');
|
||||
const logsElement = document.getElementById('logs-content');
|
||||
const configForm = document.getElementById('config-form');
|
||||
const payslipsPathInput = document.getElementById('payslipsPath');
|
||||
const configStatus = document.getElementById('config-status');
|
||||
const browseBtn = document.getElementById('browse-btn');
|
||||
const dbStatusElement = document.getElementById('db-status');
|
||||
const checkDbBtn = document.getElementById('check-db-btn');
|
||||
const refreshLogsBtn = document.getElementById('refresh-logs-btn');
|
||||
const clearLogsBtn = document.getElementById('clear-logs-btn');
|
||||
const adminDbStatusEl = document.getElementById('admin-db-status');
|
||||
const adminDbMessageEl = document.getElementById('admin-db-message');
|
||||
const backendDbStatusEl = document.getElementById('backend-db-status');
|
||||
const backendDbMessageEl = document.getElementById('backend-db-message');
|
||||
const pingBtn = document.getElementById('ping-btn');
|
||||
const pingStopBtn = document.getElementById('ping-stop-btn');
|
||||
const pingClearBtn = document.getElementById('ping-clear-btn');
|
||||
const pingIpInput = document.getElementById('ping-ip');
|
||||
const pingOutputEl = document.getElementById('ping-output');
|
||||
let ws;
|
||||
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = loginForm.elements['password'].value;
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Неверный пароль');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка авторизации');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
fetch('/api/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'online') {
|
||||
statusElement.textContent = 'Онлайн';
|
||||
statusElement.style.color = 'green';
|
||||
} else {
|
||||
statusElement.textContent = 'Оффлайн';
|
||||
statusElement.style.color = 'red';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при получении статуса:', err);
|
||||
statusElement.textContent = 'Ошибка';
|
||||
statusElement.style.color = 'orange';
|
||||
});
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', fetchStatus);
|
||||
function fetchLogs() {
|
||||
logsElement.textContent = 'загрузка...';
|
||||
fetch('/api/logs')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи');
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
logsElement.textContent = data || 'Лог-файл пуст.';
|
||||
})
|
||||
.catch(err => {
|
||||
logsElement.textContent = `Ошибка при загрузке логов: ${err.message}`;
|
||||
logsElement.style.color = '#ff6b6b';
|
||||
});
|
||||
}
|
||||
function fetchConfig() {
|
||||
fetch('/api/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
payslipsPathInput.value = config.payslipsPath;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при получении конфигурации:', err);
|
||||
configStatus.textContent = 'Не удалось загрузить конфигурацию.';
|
||||
configStatus.style.color = 'red';
|
||||
});
|
||||
}
|
||||
|
||||
configForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const newConfig = {
|
||||
payslipsPath: payslipsPathInput.value
|
||||
};
|
||||
|
||||
apiFetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newConfig)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
configStatus.textContent = 'Конфигурация успешно сохранена!';
|
||||
configStatus.style.color = 'green';
|
||||
setTimeout(() => { configStatus.textContent = ''; }, 3000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при сохранении конфигурации:', err);
|
||||
configStatus.textContent = 'Ошибка при сохранении.';
|
||||
configStatus.style.color = 'red';
|
||||
});
|
||||
});
|
||||
browseBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/browse');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
payslipsPathInput.value = data.path;
|
||||
} else {
|
||||
console.info('Folder selection was canceled.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening folder dialog:', error);
|
||||
alert('Не удалось открыть диалог выбора папки.');
|
||||
}
|
||||
});
|
||||
|
||||
function renderStatus(element, messageEl, data) {
|
||||
messageEl.textContent = data.message;
|
||||
if (data.status === 'online') {
|
||||
element.textContent = 'Онлайн';
|
||||
element.style.color = 'green';
|
||||
} else {
|
||||
element.textContent = 'Оффлайн';
|
||||
element.style.color = 'red';
|
||||
}
|
||||
}
|
||||
function checkDbStatus() {
|
||||
adminDbStatusEl.textContent = 'проверка...';
|
||||
backendDbStatusEl.textContent = 'проверка...';
|
||||
adminDbMessageEl.textContent = '';
|
||||
backendDbMessageEl.textContent = '';
|
||||
adminDbStatusEl.style.color = '#333';
|
||||
backendDbStatusEl.style.color = '#333';
|
||||
|
||||
fetch('/api/health/check-db')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
renderStatus(adminDbStatusEl, adminDbMessageEl, data.adminToDb);
|
||||
renderStatus(backendDbStatusEl, backendDbMessageEl, data.backendToDb);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при проверке статуса БД:', err);
|
||||
adminDbStatusEl.textContent = 'Ошибка';
|
||||
adminDbStatusEl.style.color = 'orange';
|
||||
backendDbStatusEl.textContent = 'Ошибка';
|
||||
backendDbStatusEl.style.color = 'orange';
|
||||
});
|
||||
}
|
||||
|
||||
checkDbBtn.addEventListener('click', checkDbStatus);
|
||||
|
||||
function clearLogs() {
|
||||
if (!confirm('Это действие удалит лог-файл полностью и навсегда. Вы уверены?')) {
|
||||
return;
|
||||
}
|
||||
fetch('/api/logs/clear', { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
fetchLogs();
|
||||
} else {
|
||||
alert('Не удалось очистить логи.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка при очистке логов:', err);
|
||||
alert('Произошла ошибка при очистке логов.');
|
||||
});
|
||||
}
|
||||
|
||||
refreshLogsBtn.addEventListener('click', fetchLogs);
|
||||
clearLogsBtn.addEventListener('click', clearLogs);
|
||||
|
||||
function doPing() {
|
||||
const ip = pingIpInput.value.trim();
|
||||
if (!ip) {
|
||||
pingOutputEl.textContent = 'Пожалуйста, введите IP-адрес.';
|
||||
return;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${wsProtocol}://${window.location.host}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
pingOutputEl.textContent = '';
|
||||
pingBtn.disabled = true;
|
||||
pingStopBtn.disabled = false;
|
||||
ws.send(JSON.stringify({ type: 'ping', ip }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
pingOutputEl.textContent += message.data;
|
||||
pingOutputEl.scrollTop = pingOutputEl.scrollHeight;
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket Error:', error);
|
||||
pingOutputEl.textContent += '\nОшибка WebSocket соединения.';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.info('WebSocket connection closed');
|
||||
pingBtn.disabled = false;
|
||||
pingStopBtn.disabled = true;
|
||||
ws = null;
|
||||
};
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPingOutput() {
|
||||
pingOutputEl.textContent = '';
|
||||
}
|
||||
pingBtn.addEventListener('click', doPing);
|
||||
pingStopBtn.addEventListener('click', stopPing);
|
||||
pingClearBtn.addEventListener('click', clearPingOutput);
|
||||
|
||||
const cardLogsElement = document.getElementById('card-logs-content');
|
||||
const refreshCardLogsBtn = document.getElementById('refresh-card-logs-btn');
|
||||
|
||||
function fetchCardLogs() {
|
||||
cardLogsElement.textContent = 'загрузка...';
|
||||
fetch('/api/card/card-logs')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Не удалось загрузить логи считывателя');
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
cardLogsElement.textContent = data || 'Лог-файл пуст.';
|
||||
})
|
||||
.catch(err => {
|
||||
cardLogsElement.textContent = `Ошибка при загрузке логов: ${err.message}`;
|
||||
cardLogsElement.style.color = '#ff6b6b';
|
||||
});
|
||||
}
|
||||
refreshCardLogsBtn.addEventListener('click', fetchCardLogs);
|
||||
|
||||
// --- ТАБЫ ---
|
||||
const tabMainBtn = document.getElementById('tab-main');
|
||||
const tabSettingsBtn = document.getElementById('tab-settings');
|
||||
const tabMonitoringBtn = document.getElementById('tab-monitoring');
|
||||
const mainTab = document.getElementById('main-tab');
|
||||
const settingsTab = document.getElementById('settings-tab');
|
||||
const monitoringTab = document.getElementById('monitoring-tab');
|
||||
|
||||
function showTab(tab) {
|
||||
mainTab.style.display = 'none';
|
||||
settingsTab.style.display = 'none';
|
||||
monitoringTab.style.display = 'none';
|
||||
tabMainBtn.classList.remove('active');
|
||||
tabSettingsBtn.classList.remove('active');
|
||||
tabMonitoringBtn.classList.remove('active');
|
||||
|
||||
if (tab === 'main') {
|
||||
mainTab.style.display = '';
|
||||
tabMainBtn.classList.add('active');
|
||||
} else if (tab === 'settings') {
|
||||
settingsTab.style.display = '';
|
||||
tabSettingsBtn.classList.add('active');
|
||||
initSettingsTab();
|
||||
} else if (tab === 'monitoring') {
|
||||
monitoringTab.style.display = '';
|
||||
tabMonitoringBtn.classList.add('active');
|
||||
initMonitoringTab();
|
||||
}
|
||||
}
|
||||
tabMainBtn.addEventListener('click', () => showTab('main'));
|
||||
tabSettingsBtn.addEventListener('click', () => showTab('settings'));
|
||||
tabMonitoringBtn.addEventListener('click', () => showTab('monitoring'));
|
||||
|
||||
// --- TOAST ---
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => container.removeChild(toast), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- CSRF TOKEN ---
|
||||
let csrfToken = null;
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const res = await fetch('/moders/csrf-token', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
csrfToken = data.csrfToken;
|
||||
}
|
||||
} catch (e) { csrfToken = null; }
|
||||
}
|
||||
// Обёртка для fetch с CSRF и credentials
|
||||
async function apiFetch(url, options = {}) {
|
||||
if (!options.headers) options.headers = {};
|
||||
if (['POST','PUT','DELETE'].includes((options.method||'GET').toUpperCase())) {
|
||||
if (!csrfToken) await fetchCsrfToken();
|
||||
options.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
options.credentials = 'include';
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// --- Модальное окно подтверждения ---
|
||||
const confirmModal = document.getElementById('confirm-modal');
|
||||
const confirmModalText = document.getElementById('confirm-modal-text');
|
||||
const confirmModalYes = document.getElementById('confirm-modal-yes');
|
||||
const confirmModalNo = document.getElementById('confirm-modal-no');
|
||||
|
||||
function showConfirm(text, onConfirm) {
|
||||
confirmModalText.textContent = text;
|
||||
confirmModal.style.display = 'flex';
|
||||
|
||||
const yesListener = () => {
|
||||
onConfirm();
|
||||
closeConfirm();
|
||||
};
|
||||
|
||||
const closeConfirm = () => {
|
||||
confirmModal.style.display = 'none';
|
||||
confirmModalYes.removeEventListener('click', yesListener);
|
||||
confirmModalNo.removeEventListener('click', closeConfirm);
|
||||
}
|
||||
|
||||
confirmModalYes.addEventListener('click', yesListener);
|
||||
confirmModalNo.addEventListener('click', closeConfirm);
|
||||
}
|
||||
|
||||
|
||||
// --- ВКЛАДКА МОНИТОРИНГА ---
|
||||
let monitoringInitialized = false;
|
||||
let allMonitoringEvents = []; // Кэш для всех событий
|
||||
|
||||
async function initMonitoringTab() {
|
||||
if (monitoringInitialized) return;
|
||||
monitoringInitialized = true;
|
||||
|
||||
const tableBody = document.querySelector('#monitoring-log-table tbody');
|
||||
const levelFilter = document.getElementById('log-level-filter');
|
||||
const sourceFilter = document.getElementById('log-source-filter');
|
||||
const refreshBtn = document.getElementById('refresh-monitoring-btn');
|
||||
|
||||
async function loadMonitoringLogs() {
|
||||
tableBody.innerHTML = '<tr><td colspan="4">Загрузка...</td></tr>';
|
||||
try {
|
||||
const res = await apiFetch('/api/monitoring/logs');
|
||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
||||
|
||||
allMonitoringEvents = await res.json();
|
||||
renderTable();
|
||||
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" style="color: red;">Ошибка загрузки логов.</td></tr>';
|
||||
console.error('Error loading monitoring logs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const level = levelFilter.value;
|
||||
const source = sourceFilter.value;
|
||||
|
||||
const filteredEvents = allMonitoringEvents.filter(event => {
|
||||
const levelMatch = (level === 'all') || (event.level === level);
|
||||
const sourceMatch = (source === 'all') || (event.source === source);
|
||||
return levelMatch && sourceMatch;
|
||||
});
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
if (filteredEvents.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4">Нет событий для отображения.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredEvents.forEach(event => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `log-level-${event.level.toLowerCase()}`;
|
||||
tr.innerHTML = `
|
||||
<td>${new Date(event.timestamp).toLocaleString()}</td>
|
||||
<td><span class="log-level-label">${event.level}</span></td>
|
||||
<td>${event.source}</td>
|
||||
<td>${event.message}</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
levelFilter.addEventListener('change', renderTable);
|
||||
sourceFilter.addEventListener('change', renderTable);
|
||||
refreshBtn.addEventListener('click', loadMonitoringLogs);
|
||||
|
||||
loadMonitoringLogs();
|
||||
}
|
||||
|
||||
|
||||
// --- НАСТРОЙКИ: инициализация вкладки ---
|
||||
let settingsInitialized = false;
|
||||
|
||||
async function initSettingsTab() {
|
||||
if(settingsInitialized) return;
|
||||
settingsInitialized = true;
|
||||
|
||||
initServerParamsSection();
|
||||
initDbSection();
|
||||
initCorsSection();
|
||||
initModersSection();
|
||||
|
||||
settingsTab.addEventListener('click', (e) => {
|
||||
if (e.target.dataset.action === 'edit-pass') {
|
||||
const targetId = e.target.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
input.readOnly = false;
|
||||
input.value = '';
|
||||
input.placeholder = 'Введите новый пароль...';
|
||||
input.focus();
|
||||
e.target.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initServerParamsSection() {
|
||||
// Загрузка текущих значений
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
apiFetch('/api/settings/admin-server'),
|
||||
apiFetch('/api/settings/backend-server')
|
||||
]);
|
||||
|
||||
const adminResult = results[0];
|
||||
if (adminResult.status === 'fulfilled') {
|
||||
const adminData = await adminResult.value.json();
|
||||
document.getElementById('admin-port').value = adminData.PORT || '';
|
||||
document.getElementById('admin-url').value = adminData.ADMIN_URL || '';
|
||||
} else {
|
||||
console.error('Failed to load admin-server settings:', adminResult.reason);
|
||||
}
|
||||
|
||||
const backendResult = results[1];
|
||||
if (backendResult.status === 'fulfilled') {
|
||||
const backendData = await backendResult.value.json();
|
||||
document.getElementById('backend-host').value = backendData.host || '';
|
||||
document.getElementById('backend-port').value = backendData.port || '';
|
||||
} else {
|
||||
console.error('Failed to load backend-server settings:', backendResult.reason);
|
||||
document.getElementById('server-params-status').textContent += ' Ошибка загрузки параметров Backend.';
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('server-params-status').textContent = 'Критическая ошибка загрузки параметров';
|
||||
document.getElementById('server-params-status').style.color = 'red';
|
||||
}
|
||||
// Обработка сохранения
|
||||
document.getElementById('server-params-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const PORT = document.getElementById('admin-port').value;
|
||||
const ADMIN_URL = document.getElementById('admin-url').value;
|
||||
const host = document.getElementById('backend-host').value;
|
||||
const port = document.getElementById('backend-port').value;
|
||||
const statusEl = document.getElementById('server-params-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const [adminRes, backendRes] = await Promise.all([
|
||||
apiFetch('/api/settings/admin-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ PORT, ADMIN_URL })
|
||||
}),
|
||||
apiFetch('/api/settings/backend-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, port })
|
||||
})
|
||||
]);
|
||||
if (adminRes.ok && backendRes.ok) {
|
||||
statusEl.innerHTML = 'Параметры успешно сохранены.<br><strong class="warning-text">Требуется перезапуск обоих серверов для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Параметры успешно сохранены', 'success');
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDbSection() {
|
||||
async function loadMysql() {
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql');
|
||||
const data = await res.json();
|
||||
document.getElementById('mysql-host').value = data.MYSQL_HOST || '';
|
||||
document.getElementById('mysql-port').value = data.MYSQL_PORT || '';
|
||||
document.getElementById('mysql-user').value = data.MYSQL_USER || '';
|
||||
|
||||
const passWrapper = document.getElementById('mysql-pass').closest('.password-wrapper');
|
||||
passWrapper.innerHTML = `
|
||||
<input type="password" id="mysql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mysql-pass">Изменить</button>
|
||||
`;
|
||||
if (data.MYSQL_PASSWORD) {
|
||||
passWrapper.querySelector('input').placeholder = '********';
|
||||
}
|
||||
|
||||
document.getElementById('mysql-db').value = data.MYSQL_DATABASE || '';
|
||||
} catch (e) {
|
||||
document.getElementById('mysql-status').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('mysql-status').style.color = 'red';
|
||||
}
|
||||
}
|
||||
loadMysql();
|
||||
document.getElementById('mysql-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
MYSQL_HOST: document.getElementById('mysql-host').value,
|
||||
MYSQL_PORT: document.getElementById('mysql-port').value,
|
||||
MYSQL_USER: document.getElementById('mysql-user').value,
|
||||
MYSQL_DATABASE: document.getElementById('mysql-db').value
|
||||
};
|
||||
const passInput = document.getElementById('mysql-pass');
|
||||
if (!passInput.readOnly) {
|
||||
payload.MYSQL_PASSWORD = passInput.value;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('mysql-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/mysql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.innerHTML = 'Сохранено.<br><strong class="warning-text">Требуется перезапуск Admin-server для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Сохранено', 'success');
|
||||
loadMysql(); // Перезагружаем секцию, чтобы сбросить поле пароля
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initCorsSection() {
|
||||
// --- CORS ---
|
||||
let allowedOrigins = [];
|
||||
async function loadCors() {
|
||||
const list = document.getElementById('cors-list');
|
||||
list.innerHTML = '<li>Загрузка...</li>';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors');
|
||||
const data = await res.json();
|
||||
allowedOrigins = Array.isArray(data.ALLOWED_ORIGINS) ? data.ALLOWED_ORIGINS : [];
|
||||
renderCorsList();
|
||||
} catch (e) {
|
||||
list.innerHTML = '<li>Ошибка загрузки</li>';
|
||||
}
|
||||
}
|
||||
const corsInput = document.getElementById('cors-new-origin');
|
||||
const corsAddBtn = document.getElementById('cors-add-btn');
|
||||
corsInput.addEventListener('input', () => {
|
||||
const val = corsInput.value.trim();
|
||||
corsAddBtn.disabled = !val || allowedOrigins.includes(val);
|
||||
});
|
||||
function renderCorsList() {
|
||||
const list = document.getElementById('cors-list');
|
||||
list.innerHTML = '';
|
||||
if (!allowedOrigins.length) {
|
||||
list.innerHTML = '<li>Нет origin</li>';
|
||||
return;
|
||||
}
|
||||
allowedOrigins.forEach((origin, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = origin + ' ';
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = 'Удалить';
|
||||
delBtn.className = 'btn-small';
|
||||
delBtn.addEventListener('click', () => {
|
||||
allowedOrigins.splice(idx, 1);
|
||||
renderCorsList();
|
||||
corsAddBtn.disabled = !corsInput.value.trim() || allowedOrigins.includes(corsInput.value.trim());
|
||||
});
|
||||
li.appendChild(delBtn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
corsAddBtn.disabled = !corsInput.value.trim() || allowedOrigins.includes(corsInput.value.trim());
|
||||
}
|
||||
loadCors();
|
||||
document.getElementById('cors-add-btn').addEventListener('click', () => {
|
||||
const input = document.getElementById('cors-new-origin');
|
||||
const val = input.value.trim();
|
||||
if (val && !allowedOrigins.includes(val)) {
|
||||
allowedOrigins.push(val);
|
||||
input.value = '';
|
||||
renderCorsList();
|
||||
corsAddBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
document.getElementById('cors-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const statusEl = document.getElementById('cors-status');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await apiFetch('/api/settings/cors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ALLOWED_ORIGINS: allowedOrigins })
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.innerHTML = 'Сохранено.<br><strong class="warning-text">Требуется перезапуск Admin-server для применения!</strong>';
|
||||
statusEl.style.color = 'green';
|
||||
showToast('Сохранено', 'success');
|
||||
} else {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initModersSection() {
|
||||
// --- МОДЕРАТОРЫ ---
|
||||
async function loadModers() {
|
||||
const tableBody = document.querySelector('#moders-table tbody');
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Загрузка...</td></tr>';
|
||||
try {
|
||||
const res = await apiFetch('/api/moders');
|
||||
const moders = await res.json();
|
||||
if (!Array.isArray(moders) || !moders.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Нет модераторов</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = '';
|
||||
for (const moder of moders) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = moder.id;
|
||||
tr.innerHTML = `<td>${moder.id}</td><td>${moder.username}</td>` +
|
||||
`<td>` +
|
||||
`<button class="btn-small" data-action="reset" title="Сменить пароль">🔑</button> ` +
|
||||
`<button class="btn-small" data-action="delete" title="Удалить">🗑️</button>` +
|
||||
`</td>`;
|
||||
tableBody.appendChild(tr);
|
||||
}
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
loadModers();
|
||||
// Добавление модератора
|
||||
document.getElementById('add-moder-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('add-moder-username').value.trim();
|
||||
const password = document.getElementById('add-moder-password').value;
|
||||
const statusEl = document.getElementById('add-moder-status');
|
||||
statusEl.textContent = '';
|
||||
if (!username || !password) {
|
||||
statusEl.textContent = 'Заполните все поля';
|
||||
statusEl.style.color = 'red';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/moders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
statusEl.textContent = 'Модератор добавлен';
|
||||
statusEl.style.color = 'green';
|
||||
document.getElementById('add-moder-form').reset();
|
||||
loadModers();
|
||||
showToast('Модератор добавлен', 'success');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
statusEl.textContent = data.message || 'Ошибка';
|
||||
statusEl.style.color = 'red';
|
||||
showToast(data.message || 'Ошибка', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка';
|
||||
statusEl.style.color = 'red';
|
||||
showToast('Ошибка', 'error');
|
||||
}
|
||||
});
|
||||
// Действия: удалить/сменить пароль
|
||||
document.getElementById('moders-table').addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const id = btn.closest('tr').dataset.id;
|
||||
const action = btn.getAttribute('data-action');
|
||||
if (action === 'delete') {
|
||||
showConfirm('Удалить модератора?', async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadModers();
|
||||
showToast('Модератор удален', 'success');
|
||||
} else {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка удаления', 'error');
|
||||
}
|
||||
});
|
||||
} else if (action === 'reset') {
|
||||
const row = btn.closest('tr');
|
||||
const actionsCell = row.cells[2];
|
||||
|
||||
// Если мы уже в режиме редактирования, ничего не делаем
|
||||
if (actionsCell.querySelector('input')) return;
|
||||
|
||||
// Сохраняем оригинальные кнопки
|
||||
const originalButtons = actionsCell.innerHTML;
|
||||
|
||||
// Создаем поле ввода и кнопки
|
||||
actionsCell.innerHTML = `
|
||||
<input type="password" placeholder="Новый пароль" class="moder-pass-input">
|
||||
<button class="btn-small" data-action="save-pass" title="Сохранить">✅</button>
|
||||
<button class="btn-small" data-action="cancel-pass" title="Отмена">❌</button>
|
||||
`;
|
||||
|
||||
const input = actionsCell.querySelector('input');
|
||||
input.focus();
|
||||
|
||||
const saveBtn = actionsCell.querySelector('[data-action="save-pass"]');
|
||||
const cancelBtn = actionsCell.querySelector('[data-action="cancel-pass"]');
|
||||
|
||||
const handleSave = async () => {
|
||||
const newPass = input.value;
|
||||
if (!newPass || newPass.length < 4) {
|
||||
showToast('Пароль должен быть не менее 4 символов', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/moders/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: newPass })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Пароль обновлён', 'success');
|
||||
actionsCell.innerHTML = originalButtons; // Возвращаем кнопки
|
||||
} else {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка смены пароля', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
actionsCell.innerHTML = originalButtons;
|
||||
};
|
||||
|
||||
saveBtn.addEventListener('click', handleSave);
|
||||
cancelBtn.addEventListener('click', handleCancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Модератор: наполнение вкладок</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="notify"></div>
|
||||
<div class="container">
|
||||
<h1>Панель модератора</h1>
|
||||
<div id="login-section">
|
||||
<h2>Вход для модератора</h2>
|
||||
<form id="moder-login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Логин:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
<p id="login-error" style="color: red;"></p>
|
||||
</form>
|
||||
</div>
|
||||
<div id="editor-section" style="display:none;">
|
||||
<button id="logout-btn" style="float:right;">Выйти из системы</button>
|
||||
<h2>Редактирование вкладок</h2>
|
||||
<div class="tab-controls">
|
||||
<button class="tab-btn active" data-tab="info">Общая информация</button>
|
||||
<button class="tab-btn" data-tab="union">Профсоюз</button>
|
||||
</div>
|
||||
<div style="margin: 18px 0;">
|
||||
<button id="add-block-btn">Добавить блок</button>
|
||||
</div>
|
||||
<div id="blocks-list"></div>
|
||||
</div>
|
||||
<div id="edit-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span class="close" id="edit-modal-close">×</span>
|
||||
<h3 id="edit-modal-title">Блок</h3>
|
||||
<form id="edit-block-form">
|
||||
<input type="hidden" id="edit-block-id">
|
||||
<div class="form-group">
|
||||
<label for="edit-quill-editor">Холст:</label>
|
||||
<div id="edit-quill-editor" style="height: 250px;"></div>
|
||||
</div>
|
||||
<div id="edit-preview-label" style="margin-top:10px;font-weight:bold;">Предпросмотр:</div>
|
||||
<div id="edit-block-preview" class="block-text" style="min-height:40px;margin-bottom:10px;"></div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="confirm-modal" class="modal" style="display:none; z-index:2000;">
|
||||
<div class="modal-content" style="max-width:400px; text-align:center;">
|
||||
<p style="margin-bottom:20px; ">Вы уверены, что хотите закрыть окно?<br>Несохранённые изменения будут потеряны!</p>
|
||||
<button id="confirm-close-yes" style="background:#e74c3c; margin-right:12px;">Да, закрыть</button>
|
||||
<button id="confirm-close-no">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
<script src="editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const editorSection = document.getElementById('editor-section');
|
||||
const loginForm = document.getElementById('moder-login-form');
|
||||
const loginError = document.getElementById('login-error');
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
const addBlockBtn = document.getElementById('add-block-btn');
|
||||
const blocksList = document.getElementById('blocks-list');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
loginError.textContent = '';
|
||||
const username = loginForm.username.value.trim();
|
||||
const password = loginForm.password.value;
|
||||
try {
|
||||
const res = await fetch('/moders/moder-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
loginSection.style.display = 'none';
|
||||
editorSection.style.display = '';
|
||||
loadBlocks(currentTab);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
loginError.textContent = data.message || 'Ошибка входа';
|
||||
}
|
||||
} catch (err) {
|
||||
loginError.textContent = 'Ошибка сервера';
|
||||
}
|
||||
});
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = async () => {
|
||||
await fetch('/moders/moder-logout');
|
||||
editorSection.style.display = 'none';
|
||||
loginSection.style.display = '';
|
||||
showNotify('Вы вышли из системы', 'success');
|
||||
};
|
||||
}
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentTab = btn.dataset.tab;
|
||||
loadBlocks(currentTab);
|
||||
});
|
||||
});
|
||||
function loadBlocks(tab) {
|
||||
fetch(`/moders/content/${tab}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
blocksList.innerHTML = '';
|
||||
if (!Array.isArray(data) || !data.length) {
|
||||
blocksList.innerHTML = '<p>Нет блоков</p>';
|
||||
return;
|
||||
}
|
||||
data.forEach(block => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'content-block';
|
||||
div.innerHTML = `<div class="block-preview" style="margin-bottom:10px;">${DOMPurify.sanitize(block.content)}</div>`;
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.textContent = 'Редактировать';
|
||||
editBtn.onclick = () => openEditModal(block);
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = 'Удалить';
|
||||
delBtn.onclick = async () => {
|
||||
if (confirm('Удалить блок?')) {
|
||||
await fetch(`/moders/content/${tab}/${block.id}`, { method: 'DELETE' });
|
||||
showNotify('Блок удалён', 'success');
|
||||
loadBlocks(tab);
|
||||
}
|
||||
};
|
||||
div.appendChild(editBtn);
|
||||
div.appendChild(delBtn);
|
||||
blocksList.appendChild(div);
|
||||
});
|
||||
});
|
||||
}
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const editModalClose = document.getElementById('edit-modal-close');
|
||||
const editBlockForm = document.getElementById('edit-block-form');
|
||||
const editBlockId = document.getElementById('edit-block-id');
|
||||
const editModalTitle = document.getElementById('edit-modal-title');
|
||||
let editQuill;
|
||||
let currentTab = 'info';
|
||||
let editingBlockId = null;
|
||||
let originalContent = '';
|
||||
function showNotify(msg, type = 'success') {
|
||||
const notify = document.getElementById('notify');
|
||||
notify.textContent = msg;
|
||||
notify.className = 'show ' + type;
|
||||
setTimeout(() => { notify.className = ''; }, 2200);
|
||||
}
|
||||
editQuill = new Quill('#edit-quill-editor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'align': [] }],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['blockquote', 'code-block'],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
function openEditModal(block) {
|
||||
editingBlockId = block ? block.id : null;
|
||||
editBlockId.value = block ? block.id : '';
|
||||
editModalTitle.textContent = block ? 'Редактировать блок' : 'Добавить блок';
|
||||
editQuill.root.innerHTML = block ? block.content : '';
|
||||
document.getElementById('edit-block-preview').innerHTML = block ? DOMPurify.sanitize(block.content) : '';
|
||||
originalContent = block ? block.content : '';
|
||||
editModal.style.display = 'block';
|
||||
}
|
||||
editQuill.on('text-change', () => {
|
||||
document.getElementById('edit-block-preview').innerHTML = DOMPurify.sanitize(editQuill.root.innerHTML);
|
||||
});
|
||||
function tryCloseEditModal() {
|
||||
const currentContent = editQuill.root.innerHTML;
|
||||
if (currentContent !== originalContent) {
|
||||
document.getElementById('confirm-modal').style.display = 'flex';
|
||||
const yesBtn = document.getElementById('confirm-close-yes');
|
||||
const noBtn = document.getElementById('confirm-close-no');
|
||||
yesBtn.onclick = function() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
editModal.style.display = 'none';
|
||||
};
|
||||
noBtn.onclick = function() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
};
|
||||
return;
|
||||
}
|
||||
editModal.style.display = 'none';
|
||||
}
|
||||
editModalClose.onclick = tryCloseEditModal;
|
||||
window.onclick = (event) => {
|
||||
if (event.target === editModal) tryCloseEditModal();
|
||||
};
|
||||
addBlockBtn.onclick = () => openEditModal(null);
|
||||
editBlockForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = DOMPurify.sanitize(editQuill.root.innerHTML.trim());
|
||||
if (!content || content === '<p><br></p>') return;
|
||||
if (editingBlockId) {
|
||||
await fetch(`/moders/content/${currentTab}/${editingBlockId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
showNotify('Блок обновлён', 'success');
|
||||
} else {
|
||||
await fetch(`/moders/content/${currentTab}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
showNotify('Блок добавлен', 'success');
|
||||
}
|
||||
editModal.style.display = 'none';
|
||||
loadBlocks(currentTab);
|
||||
});
|
||||
fetch('/moders/check-session', { credentials: 'include' }).then(res => {
|
||||
if (res.ok) {
|
||||
loginSection.style.display = 'none';
|
||||
editorSection.style.display = '';
|
||||
loadBlocks(currentTab);
|
||||
} else {
|
||||
loginSection.style.display = '';
|
||||
editorSection.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
let csrfToken = '';
|
||||
fetch('/moders/csrf-token').then(res => res.json()).then(data => {
|
||||
csrfToken = data.csrfToken;
|
||||
});
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
if (options && options.method && ['POST','PUT','DELETE'].includes(options.method.toUpperCase())) {
|
||||
options.headers = options.headers || {};
|
||||
options.headers['x-csrf-token'] = csrfToken;
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Панель администратора</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container"></div>
|
||||
<div class="container">
|
||||
<h1>Панель администратора</h1>
|
||||
<div class="tabs">
|
||||
<button id="tab-main" class="tab-btn active">Общее</button>
|
||||
<button id="tab-settings" class="tab-btn">Настройки</button>
|
||||
<button id="tab-monitoring" class="tab-btn">Мониторинг</button>
|
||||
</div>
|
||||
<div id="main-tab">
|
||||
<div id="status">
|
||||
<p>Статус сервера: <span id="server-status">загрузка...</span></p>
|
||||
<button id="refresh-status-btn" class="btn-small">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="db-status-container">
|
||||
<div>
|
||||
<p>Админ -> БД: <span id="admin-db-status">неизвестно</span></p>
|
||||
<small id="admin-db-message"></small>
|
||||
</div>
|
||||
<div>
|
||||
<p>Программа -> БД: <span id="backend-db-status">неизвестно</span></p>
|
||||
<small id="backend-db-message"></small>
|
||||
</div>
|
||||
<button id="check-db-btn" class="btn-small">Проверить</button>
|
||||
</div>
|
||||
|
||||
<div class="network-diag-container">
|
||||
<h2>Сетевая диагностика (Ping)</h2>
|
||||
<div class="ping-controls">
|
||||
<input type="text" id="ping-ip" placeholder="Введите IP-адрес или хост">
|
||||
<button id="ping-btn" class="btn-small">Пинг</button>
|
||||
<button id="ping-stop-btn" class="btn-small" disabled>Стоп</button>
|
||||
<button id="ping-clear-btn" class="btn-small">Очистить</button>
|
||||
</div>
|
||||
<pre id="ping-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<h2>Логи приложения</h2>
|
||||
<div class="log-controls">
|
||||
<button id="refresh-logs-btn" class="btn-small">Обновить логи</button>
|
||||
<button id="clear-logs-btn" class="btn-small">Очистить</button>
|
||||
</div>
|
||||
<pre id="logs-content">Нажмите "Обновить логи" для просмотра</pre>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<h2>Логи считывателя</h2>
|
||||
<div class="log-controls">
|
||||
<button id="refresh-card-logs-btn" class="btn-small">Обновить логи считывателя</button>
|
||||
</div>
|
||||
<pre id="ping-output">
|
||||
<pre id="card-logs-content">Нажмите "Обновить логи считывателя" для просмотра</pre>
|
||||
</pre></pre>
|
||||
</div>
|
||||
|
||||
<div class="config-container">
|
||||
<h2>Конфигурация</h2>
|
||||
<form id="config-form">
|
||||
<div class="form-group">
|
||||
<label for="payslipsPath">Путь к расчетным листкам:</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="payslipsPath" name="payslipsPath" required>
|
||||
<button type="button" id="browse-btn">Обзор...</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p id="config-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-tab" style="display:none">
|
||||
<h2>Настройки</h2>
|
||||
<div id="server-params-section"><h3>Параметры серверов</h3>
|
||||
<form id="server-params-form" novalidate>
|
||||
<fieldset><legend>Admin-server</legend>
|
||||
<label>PORT: <input type="number" id="admin-port" min="1" max="65535" required></label><br>
|
||||
<label>ADMIN_URL: <input type="text" id="admin-url" pattern="https?://.+" required></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Backend</legend>
|
||||
<label>host: <input type="text" id="backend-host" required></label><br>
|
||||
<label>port: <input type="number" id="backend-port" min="1" max="65535" required></label>
|
||||
</fieldset>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="server-params-status" class="form-error"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="db-section"><h3>Подключения к БД</h3>
|
||||
<form id="mysql-form" novalidate><fieldset><legend>MySQL (модераторы)</legend>
|
||||
<label>MYSQL_HOST: <input type="text" id="mysql-host" required></label><br>
|
||||
<label>MYSQL_PORT: <input type="number" id="mysql-port" min="1" max="65535" required></label><br>
|
||||
<label>MYSQL_USER: <input type="text" id="mysql-user" required></label><br>
|
||||
<label>MYSQL_PASSWORD:
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="mysql-pass" readonly>
|
||||
<button type="button" class="btn-small" data-action="edit-pass" data-target="mysql-pass">Изменить</button>
|
||||
</div>
|
||||
</label><br>
|
||||
<label>MYSQL_DATABASE: <input type="text" id="mysql-db" required></label><br>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="mysql-status" class="form-error"></span>
|
||||
</fieldset></form>
|
||||
</div>
|
||||
<div id="cors-section"><h3>CORS (ALLOWED_ORIGINS)</h3>
|
||||
<form id="cors-form">
|
||||
<ul id="cors-list"></ul>
|
||||
<input type="text" id="cors-new-origin" placeholder="Добавить origin...">
|
||||
<button type="button" id="cors-add-btn" disabled>Добавить</button>
|
||||
<button type="submit">Сохранить</button>
|
||||
<span id="cors-status" class="form-error"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="moders-section"><h3>Модераторы</h3>
|
||||
<form id="add-moder-form" novalidate>
|
||||
<input type="text" id="add-moder-username" placeholder="Логин" required>
|
||||
<input type="password" id="add-moder-password" placeholder="Пароль (мин. 4 символа)" minlength="4" required>
|
||||
<button type="submit">Добавить</button>
|
||||
<span id="add-moder-status" class="form-error"></span>
|
||||
</form>
|
||||
<table id="moders-table" class="moders-table"><thead><tr><th>ID</th><th>Логин</th><th>Действия</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoring-tab" style="display:none;">
|
||||
<h2>Мониторинг системы</h2>
|
||||
<div id="monitoring-status-container">
|
||||
<!-- Статусы будут здесь -->
|
||||
</div>
|
||||
|
||||
<h3>Проблемные события (Warn/Error)</h3>
|
||||
<div class="monitoring-filters">
|
||||
<label>Уровень:
|
||||
<select id="log-level-filter">
|
||||
<option value="all">Все</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Источник:
|
||||
<select id="log-source-filter">
|
||||
<option value="all">Все</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Backend">Backend</option>
|
||||
<option value="CardReader">CardReader</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="refresh-monitoring-btn" class="btn-small">Обновить</button>
|
||||
</div>
|
||||
<table id="monitoring-log-table" class="moders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">Время</th>
|
||||
<th style="width: 10%;">Уровень</th>
|
||||
<th style="width: 15%;">Источник</th>
|
||||
<th>Сообщение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Данные будут здесь -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для подтверждений -->
|
||||
<div id="confirm-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<p id="confirm-modal-text">Вы уверены?</p>
|
||||
<div class="modal-buttons">
|
||||
<button id="confirm-modal-yes" class="btn-danger">Да</button>
|
||||
<button id="confirm-modal-no" class="btn-secondary">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Вход в панель администратора</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Вход</h1>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<p id="error-message" style="color: red;"></p>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
document.getElementById('error-message').textContent = 'Неверный пароль';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,557 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f4f7f9;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 80%;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
#refresh-status-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#server-status {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
#logs-content {
|
||||
background-color: #2d2d2d;
|
||||
color: #f1f1f1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
white-space: pre-wrap; /* Перенос строк */
|
||||
word-wrap: break-word; /* Перенос длинных строк */
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex-grow: 1; /* Поле ввода займет все доступное место */
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #0056b3;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #004494;
|
||||
}
|
||||
|
||||
#db-status-container {
|
||||
display: flex;
|
||||
align-items: flex-start; /* Выравнивание по верху */
|
||||
gap: 20px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#db-status-container p {
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
#db-status-container small {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#refresh-status-btn, .btn-small {
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.network-diag-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.ping-controls {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ping-controls input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#ping-output {
|
||||
background-color: #2d2d2d;
|
||||
color: #f1f1f1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
/* --- Карточки блоков --- */
|
||||
.content-block {
|
||||
background: #f9f9fb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
||||
padding: 18px 18px 12px 18px;
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.content-block:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.13);
|
||||
}
|
||||
.block-text, .block-image, .block-video {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.block-image img, .block-video video {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* --- Кнопки --- */
|
||||
button, .btn-small, .tab-btn {
|
||||
background: #0056b3;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 16px;
|
||||
margin-right: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
button:hover, .btn-small:hover, .tab-btn:hover {
|
||||
background: #003e80;
|
||||
}
|
||||
button:active, .btn-small:active, .tab-btn:active {
|
||||
background: #002b5c;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: #0074e4;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* --- Модальное окно --- */
|
||||
.modal {
|
||||
display: none; /* Скрыто по умолчанию */
|
||||
position: fixed; /* Остается на месте при скролле */
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto; /* Включает скролл, если нужно */
|
||||
background-color: rgba(0,0,0,0.5); /* Полупрозрачный фон */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 80%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.monitoring-filters {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.monitoring-filters label {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.log-level-label {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
tr.log-level-error {
|
||||
background-color: #ffdddd;
|
||||
}
|
||||
tr.log-level-error .log-level-label {
|
||||
background-color: #d63031;
|
||||
}
|
||||
|
||||
tr.log-level-warn {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
tr.log-level-warn .log-level-label {
|
||||
background-color: #fdcb6e;
|
||||
}
|
||||
|
||||
|
||||
.modal-buttons {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
margin: 0 10px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 12px;
|
||||
font-size: 2rem;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.close:hover { color: #0056b3; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideDown { from { transform: translateY(-30px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* --- Уведомления --- */
|
||||
#notify {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 2000;
|
||||
min-width: 220px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.13);
|
||||
padding: 16px 22px;
|
||||
font-size: 16px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
#notify.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#notify.error {
|
||||
background: #ffeaea;
|
||||
color: #b30000;
|
||||
border: 1px solid #ffb3b3;
|
||||
}
|
||||
#notify.success {
|
||||
background: #eaffea;
|
||||
color: #1a7f1a;
|
||||
border: 1px solid #b3ffb3;
|
||||
}
|
||||
|
||||
/* --- Формы --- */
|
||||
input[type="text"], input[type="password"], textarea, select {
|
||||
border: 1.5px solid #b3c6e0;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border 0.15s;
|
||||
}
|
||||
input[type="text"]:focus, input[type="password"]:focus, textarea:focus, select:focus {
|
||||
border: 1.5px solid #0056b3;
|
||||
outline: none;
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
/* --- Logout --- */
|
||||
#logout-btn {
|
||||
background: #e74c3c;
|
||||
float: right;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
padding: 7px 16px;
|
||||
}
|
||||
#logout-btn:hover { background: #c0392b; }
|
||||
|
||||
/* Кнопка "Добавить блок" */
|
||||
#add-block-btn {
|
||||
background: #0074e4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 18px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#add-block-btn:hover { background: #005bb5; }
|
||||
|
||||
/* Кнопка "Редактировать" */
|
||||
.content-block button:nth-of-type(1) {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 6px 14px;
|
||||
margin-right: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.content-block button:nth-of-type(1):hover { background: #e65100; }
|
||||
|
||||
/* Кнопка "Удалить" */
|
||||
.content-block button:nth-of-type(2) {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 6px 14px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.content-block button:nth-of-type(2):hover { background: #b71c1c; }
|
||||
|
||||
/* Кнопка "Сохранить" в модалке */
|
||||
#edit-block-form button[type="submit"] {
|
||||
background: #43a047;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 18px;
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#edit-block-form button[type="submit"]:hover { background: #2e7031; }
|
||||
|
||||
/* --- Адаптивность --- */
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 8px; }
|
||||
.modal-content { min-width: 90vw; padding: 18px 8px; }
|
||||
#notify { right: 8px; top: 8px; min-width: 120px; font-size: 14px; }
|
||||
}
|
||||
|
||||
#edit-block-preview {
|
||||
max-width: 100%;
|
||||
max-height: 45vh; /* Ограничение по высоте предпросмотра */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#confirm-modal .modal-content {
|
||||
max-width: 400px;
|
||||
padding: 10px 28px 24px 28px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.22);
|
||||
}
|
||||
#confirm-modal h3 {
|
||||
font-size: 1.15rem;
|
||||
color: #222;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#confirm-close-yes {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 18px;
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#confirm-close-yes:hover { background: #b71c1c; }
|
||||
#confirm-close-no {
|
||||
background: #f4f7f9;
|
||||
color: #222;
|
||||
border: 1.5px solid #b3c6e0;
|
||||
border-radius: 5px;
|
||||
padding: 8px 18px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#confirm-close-no:hover { background: #e0e7ef; }
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast {
|
||||
visibility: hidden;
|
||||
min-width: 250px;
|
||||
max-width: 350px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 14px 22px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
font-size: 16px;
|
||||
opacity: 0.97;
|
||||
animation: fadein 0.3s, fadeout 0.3s 2.7s;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.toast-success { background: #2ecc40; color: #fff; }
|
||||
.toast-error { background: #ff4136; color: #fff; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(-20px);} to { opacity: 0.97; transform: translateY(0);} }
|
||||
@keyframes fadeout { from { opacity: 0.97; } to { opacity: 0; } }
|
||||
|
||||
/* --- Настройки: секции и формы --- */
|
||||
#settings-tab > div {
|
||||
margin-bottom: 32px;
|
||||
padding: 18px 20px 16px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
border: 1px solid #e3e8ee;
|
||||
}
|
||||
#settings-tab h3 { margin-top: 0; }
|
||||
fieldset {
|
||||
border: 1px solid #bfc9d8;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px 10px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
legend { color: #0056b3; font-weight: 500; }
|
||||
input[type="text"], input[type="password"], input[type="number"] {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #bfc9d8;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0 10px 0;
|
||||
width: 220px;
|
||||
font-size: 15px;
|
||||
background: #fafdff;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
input:focus { border: 1.5px solid #0056b3; outline: none; background: #fff; }
|
||||
input:invalid { border: 1.5px solid #ff4136; background: #fff6f6; }
|
||||
.form-error { color: #ff4136; font-size: 14px; margin: 2px 0 8px 0; }
|
||||
.warning-text {
|
||||
color: #ff8c00; /* DarkOrange */
|
||||
}
|
||||
.btn-small {
|
||||
background: #e3e8ee;
|
||||
color: #0056b3;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 14px;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-small:hover { background: #0056b3; color: #fff; }
|
||||
button[type="submit"] {
|
||||
background: #2ecc40;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 18px;
|
||||
font-size: 15px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button[type="submit"]:hover { background: #27ae38; }
|
||||
#cors-list li { margin-bottom: 6px; }
|
||||
.moders-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.moders-table th, .moders-table td {
|
||||
border: 1px solid #e3e8ee;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.moders-table th {
|
||||
background: #f4f7fa;
|
||||
color: #0056b3;
|
||||
font-weight: 500;
|
||||
}
|
||||
.moders-table tr:nth-child(even) { background: #fafdff; }
|
||||
.moders-table tr:hover { background: #eaf3ff; }
|
||||
|
||||
.moder-pass-input {
|
||||
width: 100px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
const csurf = require('csurf');
|
||||
|
||||
const csrfProtection = csurf({ cookie: false });
|
||||
|
||||
router.post('/', authController.login);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const fs = require('fs').promises;
|
||||
const fs = require('fs-extra');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
@@ -19,7 +18,6 @@ const healthRouter = require('./routes/health');
|
||||
const monitoringRouter = require('./routes/monitoring');
|
||||
const setupWebSocket = require('./services/websocket');
|
||||
const cardController = require('./controllers/cardController');
|
||||
const foodOrdersRouter = require('./moders/routes/foodOrders');
|
||||
const app = express();
|
||||
const server = require('http').createServer(app);
|
||||
|
||||
@@ -28,29 +26,18 @@ const corsConfig = require('./config/cors');
|
||||
const sessionConfig = require('./config/session');
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Разрешаем скрипты на странице питания
|
||||
}));
|
||||
app.use(cors(corsConfig));
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.json());
|
||||
app.use(session(sessionConfig(session)));
|
||||
|
||||
// Роуты
|
||||
// Роуты, которые не требуют CSRF
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use(require('./middleware/csrf'));
|
||||
// Статические файлы
|
||||
app.use(express.static(path.join(__dirname, '..','admin-frontend', 'build')));
|
||||
app.use('/login.html', express.static(path.join(__dirname, 'public', 'login.html')));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/uploads', express.static(__dirname + '/uploads'));
|
||||
|
||||
// ============================================
|
||||
// ПИТАНИЕ (API + HTML страница)
|
||||
// ============================================
|
||||
app.use('/moders/food', foodOrdersRouter); // 👈 ВОТ ЭТА СТРОКА ИСПРАВЛЕНА
|
||||
app.get('/moders/food', (req, res) => {
|
||||
// Отдаем HTML файл
|
||||
res.sendFile(path.join(__dirname, 'views', 'food-orders.html'));
|
||||
});
|
||||
|
||||
// Роуты
|
||||
app.use('/api/card', cardRouter);
|
||||
app.use('/api/logs', logsRouter);
|
||||
@@ -60,12 +47,6 @@ app.use('/api/health', healthRouter);
|
||||
app.use('/api/monitoring', monitoringRouter);
|
||||
app.use('/moders', moderAuthRouter);
|
||||
app.use('/moders/content', moderContentRouter);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Initialize card logs
|
||||
(async () => {
|
||||
try {
|
||||
@@ -79,20 +60,11 @@ app.use((req, res, next) => {
|
||||
// WebSocket
|
||||
setupWebSocket(server);
|
||||
|
||||
// Catch-all route to serve React's index.html
|
||||
app.get(/.*/, (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/moders')) {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(path.join(__dirname, '..','admin-frontend', 'build', 'index.html'));
|
||||
});
|
||||
|
||||
// Обработчик ошибок (должен быть последним!)
|
||||
app.use(require('./middleware/errorHandler'));
|
||||
|
||||
app.get('/api/status', async (req, res) => {
|
||||
try {
|
||||
res.json({ status: 'online' });
|
||||
res.json({ status: 'online' }); // или другая логика проверки
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
}
|
||||
@@ -101,8 +73,7 @@ app.get('/api/status', async (req, res) => {
|
||||
app.get('/api/config', async (req, res) => {
|
||||
try {
|
||||
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
||||
const content = await fs.readFile(backendConfigPath, 'utf8');
|
||||
const backendConfig = JSON.parse(content);
|
||||
const backendConfig = await fs.readJson(backendConfigPath);
|
||||
const config = {
|
||||
payslipsPath: backendConfig.payslipsPath
|
||||
};
|
||||
@@ -112,18 +83,19 @@ app.get('/api/config', async (req, res) => {
|
||||
res.status(500).json({ error: 'Ошибка загрузки конфигурации' });
|
||||
}
|
||||
});
|
||||
|
||||
// Добавьте рядом с существующим GET обработчиком
|
||||
app.post('/api/config', async (req, res) => {
|
||||
try {
|
||||
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
||||
const content = await fs.readFile(backendConfigPath, 'utf8');
|
||||
const currentConfig = JSON.parse(content);
|
||||
const currentConfig = await fs.readJson(backendConfigPath);
|
||||
|
||||
// Обновляем только разрешенные поля
|
||||
if (req.body.payslipsPath) {
|
||||
currentConfig.payslipsPath = req.body.payslipsPath;
|
||||
}
|
||||
|
||||
await fs.writeFile(backendConfigPath, JSON.stringify(currentConfig, null, 4));
|
||||
// Сохраняем обновленный конфиг
|
||||
await fs.writeJson(backendConfigPath, currentConfig, { spaces: 4 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -137,7 +109,6 @@ app.post('/api/config', async (req, res) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(process.env.PORT || 3005, () => {
|
||||
logger.info(`Server running on ${process.env.ADMIN_URL}`);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 804 KiB |
|
Before Width: | Height: | Size: 804 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,460 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Заказы питания — панель модератора</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f4f7fb;
|
||||
color: #2d3436;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #2b6cb0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #2b6cb0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.nav-link:hover { text-decoration: underline; }
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.filters button:hover { background: #dde5ef; }
|
||||
.filters button.active {
|
||||
background: #2b6cb0;
|
||||
color: #fff;
|
||||
}
|
||||
.filters button .badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: rgba(255,255,255,0.3);
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px 60px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toolbar .dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2ecc71;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.orders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.order {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||||
border-left: 5px solid #bdc3c7;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.order:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.order.status-new { border-left-color: #e74c3c; }
|
||||
.order.status-accepted { border-left-color: #f39c12; }
|
||||
.order.status-done { border-left-color: #2ecc71; }
|
||||
.order.status-cancelled { border-left-color: #95a5a6; opacity: 0.7; }
|
||||
|
||||
.order-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.order-head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.order-id {
|
||||
background: #eef2f7;
|
||||
color: #2b6cb0;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.order-time {
|
||||
color: #95a5a6;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.employee {
|
||||
font-size: 15px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.employee b { color: #2d3436; }
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px dashed #eef2f7;
|
||||
border-bottom: 1px dashed #eef2f7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #2d3436;
|
||||
}
|
||||
.item-row .qty { color: #95a5a6; margin-left: 6px; font-weight: 500; }
|
||||
.order-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.total {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
}
|
||||
.total small { font-size: 13px; color: #95a5a6; font-weight: 500; }
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions button {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
.actions button:active { transform: scale(0.96); }
|
||||
.actions button:hover { opacity: 0.85; }
|
||||
.btn-accept { background: #f39c12; }
|
||||
.btn-done { background: #2ecc71; }
|
||||
.btn-cancel { background: #e74c3c; }
|
||||
.actions button:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.empty, .loading {
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
padding: 60px 20px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2d3436;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<a href="/moders" class="nav-link">← В панель модератора</a>
|
||||
<h1>🍔 Заказы питания</h1>
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<button data-status="new" class="active">Новые <span class="badge" id="b-new">0</span></button>
|
||||
<button data-status="accepted">Принятые <span class="badge" id="b-accepted">0</span></button>
|
||||
<button data-status="done">Выполненные <span class="badge" id="b-done">0</span></button>
|
||||
<button data-status="cancelled">Отменённые <span class="badge" id="b-cancelled">0</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="dot"></div>
|
||||
<span id="auto-status">Автообновление каждые 10 сек…</span>
|
||||
</div>
|
||||
<div id="container" class="orders">
|
||||
<div class="loading">Загрузка...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// Конфиг
|
||||
// ============================================================
|
||||
const API = '/moders/food/orders';
|
||||
const API_STATUS = '/moders/food/orders/';
|
||||
const REFRESH_MS = 10000;
|
||||
|
||||
let currentStatus = 'new';
|
||||
let allOrders = [];
|
||||
|
||||
const container = document.getElementById('container');
|
||||
const toastEl = document.getElementById('toast');
|
||||
|
||||
// ============================================================
|
||||
// Загрузка данных
|
||||
// ============================================================
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const r = await fetch(`${API}?status=${currentStatus}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
allOrders = data;
|
||||
render(data);
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки:', e);
|
||||
if (container.innerHTML.includes('loading')) {
|
||||
container.innerHTML = `<div class="empty">Ошибка загрузки: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Бейджи (кол-во по каждому статусу)
|
||||
// ============================================================
|
||||
async function updateBadges() {
|
||||
try {
|
||||
const statuses = ['new', 'accepted', 'done', 'cancelled'];
|
||||
const results = await Promise.all(
|
||||
statuses.map((s) =>
|
||||
fetch(`${API}?status=${s}`, { credentials: 'include' })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((arr) => arr.length)
|
||||
.catch(() => 0)
|
||||
)
|
||||
);
|
||||
statuses.forEach((s, i) => {
|
||||
const el = document.getElementById('b-' + s);
|
||||
if (el) el.textContent = results[i];
|
||||
});
|
||||
} catch (err) {
|
||||
// Игнорируем ошибки бейджей
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Рендер карточек
|
||||
// ============================================================
|
||||
function render(orders) {
|
||||
if (!orders || orders.length === 0) {
|
||||
container.innerHTML = `<div class="empty">Заказов нет</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = orders.map(renderOrder).join('');
|
||||
|
||||
// Вешаем обработчики на кнопки
|
||||
container.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.id;
|
||||
const status = btn.dataset.action;
|
||||
changeStatus(id, status, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrder(o) {
|
||||
const itemsHtml = (o.items || [])
|
||||
.map(
|
||||
(it) => `
|
||||
<div class="item-row">
|
||||
<span>${escapeHtml(it.name)}<span class="qty">× ${it.quantity}</span></span>
|
||||
<span>${Number(it.subtotal).toFixed(2)} BYN</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (o.status === 'new') {
|
||||
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Принять</button>`);
|
||||
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||
} else if (o.status === 'accepted') {
|
||||
actions.push(`<button class="btn-done" data-action="done" data-id="${o.id}">Выполнен</button>`);
|
||||
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||
} else if (o.status === 'cancelled') {
|
||||
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Восстановить</button>`);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order status-${o.status}">
|
||||
<div class="order-head">
|
||||
<h2><span class="order-id">Заказ #${o.id}</span></h2>
|
||||
<span class="order-time">${formatDate(o.createdAt)}</span>
|
||||
</div>
|
||||
<div class="employee">
|
||||
${escapeHtml(o.employee || 'Сотрудник')} · таб. № <b>${escapeHtml(o.tnom)}</b>
|
||||
</div>
|
||||
<div class="items">${itemsHtml}</div>
|
||||
<div class="order-foot">
|
||||
<div class="total">${Number(o.totalAmount).toFixed(2)} <small>BYN</small></div>
|
||||
<div class="actions">${actions.join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Смена статуса
|
||||
// ============================================================
|
||||
async function changeStatus(id, status, btn) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${API_STATUS}${id}/status`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
|
||||
showToast(`Заказ #${id} → ${statusLabel(status)}`);
|
||||
await loadOrders();
|
||||
await updateBadges();
|
||||
} catch (e) {
|
||||
showToast(`Ошибка: ${e.message}`);
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
// Кнопка вернет свой текст при перерисовке
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return { new: 'Новый', accepted: 'Принят', done: 'Выполнен', cancelled: 'Отменён' }[s] || s;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Утилиты
|
||||
// ============================================================
|
||||
function escapeHtml(str) {
|
||||
return String(str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function showToast(text) {
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Фильтры
|
||||
// ============================================================
|
||||
document.getElementById('filters').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-status]');
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('#filters button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentStatus = btn.dataset.status;
|
||||
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||||
loadOrders();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Старт и Автообновление
|
||||
// ============================================================
|
||||
loadOrders();
|
||||
updateBadges();
|
||||
|
||||
setInterval(() => {
|
||||
loadOrders();
|
||||
updateBadges();
|
||||
}, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||