Первоначальная версия проекта chinilich-1.0

This commit is contained in:
2026-05-10 16:49:04 +03:00
commit 9b598697c5
16 changed files with 2886 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
# 🧙‍♂️ Чинилыч – интеграция с GLPI API
Современный веб-интерфейс для создания и отслеживания заявок, интеграции с GLPI (версия 11+), с собственной базой знаний, тёмной темой и адаптивным дизайном.
## 📌 Основные возможности
- **Авторизация пользователей** (роли: user, admin)
- **Создание заявок**:
- ИТ-заявка (с выбором оборудования, категории, автоматической подстановкой локации)
- Анонимная жалоба
- **Просмотр своих заявок** с синхронизацией статусов из GLPI
- **Детальный просмотр заявки** с комментариями (ITILFollowup)
- **База знаний** (синхронизация статей и документов из GLPI)
- Полнотекстовый поиск (FULLTEXT)
- Ленивая загрузка документов (скачивание по требованию)
- Сортировка по дате/названию
- **Тёмная тема** (сохраняется в localStorage)
- **Адаптивный интерфейс** (мобильные устройства)
- **Поддержка прикрепления файлов** (изображения, PDF)
## 🛠 Технологии
- PHP 8.3+
- MySQL 8.0+
- GLPI API (REST)
- HTML5 / CSS3 (CSS-переменные)
- JavaScript (Fetch API, динамические формы)
## 📁 Установка и настройка
### 1. Клонирование репозитория
```bash
git clone https://your-repo/service-desk.git
cd service-desk
```
### 2. Настройка базы данных
Создайте базу данных и импортируйте структуру:
```sql
-- Выполните SQL из файла my_database.sql
-- Дополнительные колонки для знаний
ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME DEFAULT NULL;
ALTER TABLE knowledge_base ADD FULLTEXT INDEX ft_title_content (title, content);
-- Колонка для локации в формах (исправление ошибки)
ALTER TABLE forms ADD COLUMN location_id INT DEFAULT NULL;
```
### 3. Настройка конфигурации
Отредактируйте `config.php`:
```php
// База данных
define('DB_HOST', '-');
define('DB_NAME', 'my_database');
define('DB_USER', 'root');
define('DB_PASS', 'your_password');
// GLPI API
define('GLPI_API_URL', '-');
define('GLPI_APP_TOKEN', 'your_app_token');
define('GLPI_USERNAME', 'api_user');
define('GLPI_PASSWORD', 'api_password');
// Папки для файлов
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('KB_CACHE_DIR', __DIR__ . '/uploads/kb/');
```
Убедитесь, что папки `uploads/` и `uploads/kb/` созданы и доступны для записи:
```bash
mkdir -p uploads uploads/kb
chmod 755 uploads uploads/kb
```
### 4. Настройка веб-сервера (Nginx/Apache)
Пример для Nginx (корневая директория `/var/www/html`):
```nginx
server {
listen 80;
server_name service-desk.local;
root /var/www/html;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~* \.(jpg|jpeg|png|gif|css|js|pdf)$ {
expires 30d;
}
}
```
### 5. Создание первого пользователя
Вставьте в БД администратора (пароль хешируется через `password_hash`):
```sql
INSERT INTO users (username, password, email, role, last_name, first_name)
VALUES ('admin', '$2y$10$...hash...', 'admin@example.com', 'superadmin', 'Admin', 'Администратор');
```
Для генерации хеша можно использовать скрипт:
```php
<?php echo password_hash('your_password', PASSWORD_DEFAULT); ?>
```
## 🚀 Использование
### Панель пользователя
- **Создание заявки**: выберите тип, заполните поля, прикрепите файл.
- **Список заявок**: отображаются все заявки текущего пользователя с актуальным статусом.
- **Подробности**: кнопка «Подробнее» открывает модальное окно с полной информацией и комментариями.
### База знаний
- **Поиск статей** (полнотекстовый)
- **Сортировка** (по дате или по названию)
- **Просмотр статьи** (с сохранённым HTML-форматированием)
- **Скачивание файлов** (при наличии документов в GLPI)
### Для администратора
- Кнопка **«Обновить базу»** на странице знаний – выполняет инкрементальную синхронизацию статей и документов из GLPI.
- Можно настроить cron для автоматической синхронизации:
```bash
*/30 * * * * php /var/www/html/admin_sync_kb.php
```
## 🔄 Синхронизация с GLPI
- **Заявки**: статусы и приоритеты синхронизируются из GLPI при каждом просмотре списка.
- **База знаний**: синхронизируются только изменённые статьи (по полю `date_mod`). Документы скачиваются лениво – при первом запросе к файлу.
- Для корректной работы у API-пользователя GLPI должны быть права на чтение `KnowbaseItem`, `Document`, `Ticket`, `ITILFollowup`.
## 🎨 Тёмная тема
Переключатель в шапке сайта. Выбор сохраняется в `localStorage` и автоматически применяется при следующем визите.
## 📱 Адаптивность
Интерфейс корректно отображается на мобильных устройствах (ширина экрана < 640px): меню сгибается, таблицы получают горизонтальную прокрутку, модальные окна сужаются.
## 🧩 Структура проекта
```
├── config.php # Настройки БД, GLPI, константы
├── functions.php # Основные функции (аутентификация, работа с формами, знание)
├── glpi_api.php # Класс для работы с GLPI REST API
├── index.php # Страница входа
├── dashboard.php # Главная панель (заявки)
├── knowledge.php # База знаний
├── search_knowledge.php # AJAX-поиск по базе знаний
├── get_article.php # AJAX-получение статьи
├── get_document.php # Ленивая загрузка документа
├── get_ticket_details.php # Детали заявки (AJAX)
├── get_glpi_data.php # Справочные данные (локации, компьютеры, категории)
├── get_computer_location.php # Получение локации компьютера по ID
├── logout.php # Завершение сессии
├── style.css # Стили (светлая/тёмная темы)
├── admin_sync_kb.php # Скрипт для cron-синхронизации знаний
├── uploads/ # Папка для вложений пользователей
└── uploads/kb/ # Кеш документов из GLPI
```
## ⚠️ Возможные проблемы и решения
| Проблема | Решение |
|----------|---------|
| `Fatal error: Column not found 'glpi_updated_at'` | Выполните `ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME` |
| `Data truncated for column 'status'` | Исправлена в `functions.php` – маппинг статусов GLPI в допустимые значения `pending/processing/completed/rejected` |
| Файлы в базе знаний не скачиваются | Проверьте права на папку `uploads/kb/` (755) и наличие у API-пользователя доступа к документам GLPI |
| Не работает автоподстановка локации | Убедитесь, что у компьютера в GLPI заполнено поле `locations_id` |
| Синхронизация долгая | Установите cron и уберите автоматический вызов `syncKnowledgeBaseIncremental` из `knowledge.php` (сейчас синхронизация только по кнопке) |
+11
View File
@@ -0,0 +1,11 @@
<?php
// Скрипт для cron или ручного вызова из командной строки
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/glpi_api.php';
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
$log = [];
syncKnowledgeBaseIncremental($pdo, $glpi, $log);
foreach ($log as $line) {
echo $line . PHP_EOL;
}
+50
View File
@@ -0,0 +1,50 @@
<?php
session_start();
// Настройки базы данных
define('DB_HOST', '-');
define('DB_PORT', '-');
define('DB_NAME', '-');
define('DB_USER', '-');
define('DB_PASS', '-');
// Настройки GLPI API
define('GLPI_API_URL', '-');
define('GLPI_APP_TOKEN', '-');
define('GLPI_USERNAME', '-');
define('GLPI_PASSWORD', '-');
define('GLPI_BASE_URL', '-');
define('GLPI_FRONT_URL', '-');
// Папка для загрузки вложений пользователей
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('MAX_FILE_SIZE', 5 * 1024 * 1024); // 5 МБ
// Папка для кеширования файлов из базы знаний
define('KB_CACHE_DIR', __DIR__ . '/uploads/kb/');
define('KB_CACHE_URL', '/uploads/kb/');
// Создаём папки, если их нет
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
if (!is_dir(KB_CACHE_DIR)) mkdir(KB_CACHE_DIR, 0755, true);
// Часовой пояс
date_default_timezone_set('Europe/Moscow');
// Подключение к БД
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (PDOException $e) {
die("Ошибка подключения к БД: " . $e->getMessage());
}
require_once __DIR__ . '/functions.php';
+611
View File
@@ -0,0 +1,611 @@
<?php
require_once __DIR__ . '/config.php';
$user = checkAuth($pdo);
if (!$user) {
redirect('index.php');
}
require_once __DIR__ . '/glpi_api.php';
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
$message = '';
$messageType = '';
$userForms = getUserForms($pdo, $user['id']);
// Маппинг статусов GLPI на русские
$statusMap = [
'new' => 'Новая',
'processing' => 'В обработке',
'incoming' => 'Входящая',
'waiting' => 'Ожидание',
'pending' => 'На рассмотрении',
'closed' => 'Закрыта',
'solved' => 'Решена',
'assigned' => 'Назначена',
'rejected' => 'Отклонена'
];
// Синхронизация с GLPI
$updatedForms = [];
foreach ($userForms as $form) {
if (syncFormWithGlpi($pdo, $form, $glpi)) {
$updatedForms[] = $form;
}
}
$userForms = $updatedForms;
// Обработка создания заявки
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_request'])) {
$formType = $_POST['form_type'] ?? '';
$priority = $_POST['priority'] ?? 'medium';
$locationId = !empty($_POST['location']) ? (int)$_POST['location'] : null;
$allowedTypes = ['it_request', 'anonymous_complaint'];
if (!in_array($formType, $allowedTypes)) {
$message = 'Выберите корректный тип заявки';
$messageType = 'danger';
} else {
$formData = [];
$glpiDescription = '';
$uploadedFilePath = null;
if (!empty($_FILES['attachment']) && $_FILES['attachment']['error'] !== UPLOAD_ERR_NO_FILE) {
$uploadedFilePath = uploadFile($_FILES['attachment']);
if (!$uploadedFilePath) {
$message = 'Ошибка загрузки файла. Проверьте размер и тип.';
$messageType = 'danger';
}
}
if (!$message) {
if ($formType === 'it_request') {
$computerId = $_POST['computer_id'] ?? '';
$categoryId = $_POST['itil_category'] ?? '';
$description = trim($_POST['description'] ?? '');
if (!$computerId || !$categoryId || empty($description)) {
$message = 'Заполните все обязательные поля IT-заявки';
$messageType = 'danger';
} else {
// Получаем читаемые имена из GLPI
$computerName = $glpi->getComputerName((int)$computerId);
$categoryName = $glpi->getCategoryName((int)$categoryId);
// Данные пользователя
$userFullName = trim($user['last_name'] . ' ' . $user['first_name'] . ' ' . $user['patronymic']);
$userDepartment = $user['department'] ?? 'не указан';
$userPosition = $user['position'] ?? 'не указана';
// Формируем красивое описание
$glpiDescription = "Пользователь: $userFullName\n";
$glpiDescription .= "Отдел: $userDepartment\n";
$glpiDescription .= "Должность: $userPosition\n";
$glpiDescription .= "Оборудование: $computerName\n";
$glpiDescription .= "Категория: $categoryName\n";
$glpiDescription .= "Описание проблемы:\n$description";
if ($uploadedFilePath) {
$glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath;
}
$formData = [
'computer_id' => $computerId,
'computer_name' => $computerName,
'itil_category' => $categoryId,
'category_name' => $categoryName,
'description' => $description,
'location_id' => $locationId,
'user_info' => [
'name' => $userFullName,
'department' => $userDepartment,
'position' => $userPosition
]
];
if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath;
}
} else {
$subject = trim($_POST['subject'] ?? '');
$complaintText = trim($_POST['complaint_text'] ?? '');
if (empty($subject) || empty($complaintText)) {
$message = 'Укажите тему и текст жалобы';
$messageType = 'danger';
} else {
// Для анонимной жалобы не пишем данные пользователя
$glpiDescription = "Тема: $subject\n\nТекст жалобы:\n$complaintText";
if ($uploadedFilePath) {
$glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath;
}
$formData = ['subject' => $subject, 'text' => $complaintText];
if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath;
}
}
}
if (!$message) {
$glpiData = [
'name' => $formType === 'it_request' ? 'IT-заявка' : 'Анонимная жалоба',
'content' => $glpiDescription,
'priority' => $priority === 'high' ? 4 : ($priority === 'medium' ? 2 : 1), // 1-5, обычно 3 средний, но уточните по вашей конфигурации
'type' => 1, // инцидент
'requesttypes_id' => 1, // по умолчанию
];
if ($formType === 'it_request' && !empty($computerId)) {
$glpiData['items_id'] = ['itemtype' => 'Computer', 'items_id' => (int)$computerId];
}
if ($locationId) {
$glpiData['locations_id'] = $locationId;
}
// Попробуем установить автора заявки (если текущий пользователь может создавать заявки от своего имени)
// В GLPI API можно передать _users_id_requester. Но для этого нужны права.
// Оставим как есть, GLPI сам подставит автора из сессии API.
$glpiResponse = $glpi->createTicket($glpiData);
$userIdForDb = $formType === 'it_request' ? $user['id'] : null;
$localData = [
'glpi_response' => $glpiResponse ? 'success' : 'error',
'glpi_ticket_id' => $glpiResponse['id'] ?? null,
'form_fields' => $formData,
];
createLocalForm($pdo, $userIdForDb, $formType, $localData, 'pending', $priority, $locationId);
if ($glpiResponse) {
$message = "Заявка успешно отправлена! ID GLPI: " . ($glpiResponse['id'] ?? 'N/A');
$messageType = 'success';
} else {
$message = "Ошибка при отправке в GLPI. Заявка сохранена локально.";
$messageType = 'warning';
}
}
}
}
// Повторно получаем формы после возможного обновления
$userForms = getUserForms($pdo, $user['id']);
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель заявок Service Desk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="header containers">
<div class="container">
<h1>🧙‍♂️ Чинилыч</h1>
<div class="user-info">
<span><?= htmlspecialchars($user['last_name'] . ' ' . $user['first_name']) ?></span>
<button id="themeToggle" class="theme-toggle" aria-label="Тёмная тема">🌙</button>
<a href="logout.php" class="btn btn-outline">Выход</a>
</div>
</div>
</header>
<main class="container">
<div class="nav-links">
<a href="dashboard.php" class="active">📋 Заявки</a>
<a href="knowledge.php">📚 База знаний</a>
</div>
<?php if ($message): ?>
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<div class="info-block">
<p><strong>📌 Как это работает?</strong> Выберите тип заявки: ИТ‑заявка или анонимная жалоба. Для ИТ‑заявок поля подгружаются из GLPI. При выборе оборудования локация подставится автоматически. Заполните все поля, при необходимости прикрепите файл, и нажмите «Отправить».</p>
</div>
<div class="card">
<h2> Новая заявка</h2>
<form method="post" enctype="multipart/form-data" id="requestForm">
<div class="form-group">
<label for="form_type">Тип заявки</label>
<select id="form_type" name="form_type" required>
<option value="">-- Выберите --</option>
<option value="it_request">🖥️ ИТ-заявка</option>
<option value="anonymous_complaint">🕵️ Анонимная жалоба</option>
</select>
</div>
<div class="form-group">
<label for="priority">Приоритет</label>
<select id="priority" name="priority">
<option value="low">Низкий</option>
<option value="medium" selected>Средний</option>
<option value="high">Высокий</option>
</select>
</div>
<div id="dynamicFields"></div>
<div class="form-group">
<label for="attachment">📎 Прикрепить файл (скриншот, документ)</label>
<input type="file" id="attachment" name="attachment" accept="image/*,.pdf">
<div class="hint">Максимальный размер: 5 МБ. Допустимые типы: JPG, PNG, GIF, PDF.</div>
</div>
<button type="submit" name="create_request" class="btn btn-primary">Отправить заявку</button>
</form>
</div>
<div class="card">
<h2>📋 Мои заявки</h2>
<div class="table-wrapper">
<?php if (empty($userForms)): ?>
<p style="color: var(--text-muted);">У вас пока нет заявок.</p>
<?php else: ?>
<table class="table">
<thead>
<tr><th>ID</th><th>Название</th><th>Приоритет</th><th>Статус</th><th>Дата</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($userForms as $form):
$formData = json_decode($form['data'], true);
$glpiId = $formData['glpi_ticket_id'] ?? null;
$glpiName = $formData['glpi_name'] ?? $form['type'];
$statusRaw = strtolower($form['status']);
// Перевод статуса
$statusRu = $statusMap[$statusRaw] ?? ucfirst($statusRaw);
$priorityRaw = strtolower($form['priority']);
?>
<tr>
<td>#<?= $form['id'] ?></td>
<td><?= htmlspecialchars($glpiName) ?><?php if ($glpiId): ?><br><small class="hint">GLPI #<?= $glpiId ?></small><?php endif; ?></td>
<td><span class="priority priority-<?= $priorityRaw ?>"><?= $priorityRaw === 'low' ? 'Низкий' : ($priorityRaw === 'medium' ? 'Средний' : ($priorityRaw === 'high' ? 'Высокий' : $priorityRaw)) ?></span></td>
<td><span class="status status-<?= $statusRaw ?>"><?= htmlspecialchars($statusRu) ?></span></td>
<td><?= date('d.m.Y H:i', strtotime($form['created_at'])) ?></td>
<td><?php if ($glpiId): ?><button class="btn btn-outline btn-sm view-ticket" data-ticket-id="<?= $glpiId ?>">Подробнее</button><?php else: ?><span class="hint">Ожидает GLPI</span><?php endif; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- FAQ блок -->
<div class="card">
<h2>❓ Часто задаваемые вопросы</h2>
<div class="faq-item"><input type="checkbox" id="faq1" class="faq-toggle"><label for="faq1" class="faq-question">Как долго обрабатывается заявка?</label><div class="faq-answer"><p>Обычно заявки рассматриваются в течение 2 рабочих часов.</p></div></div>
<div class="faq-item"><input type="checkbox" id="faq2" class="faq-toggle"><label for="faq2" class="faq-question">Что делать, если компьютер не загружается?</label><div class="faq-answer"><p>Создайте ИТ‑заявку с категорией «Аппаратная неисправность».</p></div></div>
<div class="faq-item"><input type="checkbox" id="faq3" class="faq-toggle"><label for="faq3" class="faq-question">Можно ли подать жалобу анонимно?</label><div class="faq-answer"><p>Да, для этого выберите тип «Анонимная жалоба».</p></div></div>
</div>
<div class="card">
<h2>🔗 Полезные ссылки</h2>
<div class="external-links">
<a href="https://glpi.yourdomain.com" target="_blank" class="btn-link">🌐 Портал GLPI</a>
<a href="https://kb.yourdomain.com" target="_blank" class="btn-link">📚 База знаний</a>
<a href="mailto:support@yourdomain.com" class="btn-link">📧 Написать в поддержку</a>
</div>
</div>
</main>
<!-- Модальное окно деталей заявки (улучшенное) -->
<div class="modal-overlay" id="ticketModal">
<div class="modal modal-ticket">
<div class="modal-header">
<h3>Заявка <span id="modalTicketId"></span></h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalContent">
<div class="loading-spinner">Загрузка...</div>
</div>
</div>
</div>
<style>
/* Стили для модального окна заявки */
.modal-ticket {
max-width: 750px;
width: 90%;
border-radius: 20px;
overflow: hidden;
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid var(--border-light);
}
.ticket-title {
font-size: 1.3rem;
font-weight: 600;
color: var(--primary-dark);
}
.ticket-status {
display: inline-block;
padding: 6px 14px;
border-radius: 30px;
font-size: 0.8rem;
font-weight: 500;
}
.info-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin-bottom: 25px;
background: var(--bg-light);
padding: 15px;
border-radius: 16px;
}
.info-label {
font-weight: 600;
color: var(--text-muted);
}
.info-value {
color: var(--text-dark);
word-break: break-word;
}
.description-box {
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 16px;
padding: 15px;
margin: 20px 0;
}
.description-box h4 {
margin-top: 0;
margin-bottom: 10px;
color: var(--primary-dark);
}
.followups-list {
margin-top: 20px;
}
.followup-card {
background: var(--bg-light);
border-radius: 16px;
padding: 15px;
margin-bottom: 15px;
transition: var(--transition);
}
.followup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 0.85rem;
color: var(--text-muted);
border-bottom: 1px dashed var(--border-light);
padding-bottom: 6px;
}
.followup-author {
font-weight: 600;
color: var(--primary-dark);
}
.followup-content {
line-height: 1.5;
white-space: pre-wrap;
}
.no-followups {
text-align: center;
color: var(--text-muted);
padding: 20px;
font-style: italic;
}
@media (max-width: 640px) {
.info-grid {
grid-template-columns: 1fr;
gap: 6px;
}
.info-label {
margin-bottom: 0;
}
.ticket-header {
flex-direction: column;
gap: 10px;
}
}
</style>
<script>
// Тёмная тема
const themeToggle = document.getElementById('themeToggle');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark');
const isDark = document.body.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
themeToggle.textContent = isDark ? '☀️' : '🌙';
});
// Динамические поля формы
const typeSelect = document.getElementById('form_type');
const dynamicContainer = document.getElementById('dynamicFields');
let glpiDataCache = { locations: null, computers: null, itilcategories: null };
async function fetchGlpiData(type) {
if (glpiDataCache[type]) return glpiDataCache[type];
try {
const response = await fetch(`get_glpi_data.php?type=${type}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
glpiDataCache[type] = data;
return data;
} catch (error) {
console.error(error);
return [];
}
}
function createSelectOptions(items) {
let options = '<option value="">-- Выберите --</option>';
items.forEach(item => { options += `<option value="${item.id}">${escapeHtml(item.name)}</option>`; });
return options;
}
async function renderFields() {
const type = typeSelect.value;
if (type === 'it_request') {
dynamicContainer.innerHTML = '<div class="loading">Загрузка справочников...</div>';
try {
const [locations, computers, categories] = await Promise.all([
fetchGlpiData('locations'),
fetchGlpiData('computers'),
fetchGlpiData('itilcategories')
]);
let computersOptions = '';
computers.forEach(item => {
computersOptions += `<option value="${escapeHtml(item.name)}" data-id="${item.id}">${escapeHtml(item.name)}</option>`;
});
let html = `
<div class="field-group">
<h3>Информация об оборудовании</h3>
<div class="form-group">
<label for="location">Местоположение</label>
<select id="location" name="location">${createSelectOptions(locations)}</select>
<div class="hint">Автоматически подставится при выборе оборудования</div>
</div>
<div class="form-group">
<label for="computer_search">Оборудование *</label>
<input type="text" id="computer_search" list="computers_list" placeholder="Начните вводить название или инв. номер" autocomplete="off" required>
<datalist id="computers_list">${computersOptions}</datalist>
<input type="hidden" id="computer_id" name="computer_id" required>
</div>
<div class="form-group">
<label for="itil_category">Категория проблемы *</label>
<select id="itil_category" name="itil_category" required>${createSelectOptions(categories)}</select>
</div>
<div class="form-group">
<label for="description">Описание проблемы *</label>
<textarea id="description" name="description" rows="4" required placeholder="Подробно опишите, что случилось..."></textarea>
</div>
</div>
`;
dynamicContainer.innerHTML = html;
const computerSearch = document.getElementById('computer_search');
const computerIdField = document.getElementById('computer_id');
const locationSelect = document.getElementById('location');
const datalist = document.getElementById('computers_list');
computerSearch.addEventListener('change', async function() {
const val = this.value;
const option = Array.from(datalist.options).find(opt => opt.value === val);
if (option && option.dataset.id) {
const id = option.dataset.id;
computerIdField.value = id;
try {
const resp = await fetch(`get_computer_location.php?id=${id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const locData = await resp.json();
if (locData.location_id && locationSelect) {
locationSelect.value = locData.location_id;
}
} catch(e) { console.error(e); }
} else {
computerIdField.value = '';
}
});
document.getElementById('requestForm').addEventListener('submit', function(e) {
if (typeSelect.value === 'it_request' && !computerIdField.value) {
e.preventDefault();
alert('Пожалуйста, выберите оборудование из списка.');
}
});
} catch (error) {
dynamicContainer.innerHTML = '<div class="error-message">Ошибка загрузки справочников.</div>';
}
} else if (type === 'anonymous_complaint') {
dynamicContainer.innerHTML = `
<div class="field-group">
<h3>Анонимная жалоба</h3>
<div class="form-group"><label for="subject">Тема *</label><input type="text" id="subject" name="subject" required placeholder="Краткая суть жалобы"></div>
<div class="form-group"><label for="complaint_text">Текст жалобы *</label><textarea id="complaint_text" name="complaint_text" rows="6" required placeholder="Опишите ситуацию..."></textarea></div>
<p class="hint">Жалоба будет отправлена анонимно, ваше имя не будет указано.</p>
</div>
`;
} else {
dynamicContainer.innerHTML = '';
}
}
typeSelect.addEventListener('change', renderFields);
if (typeSelect.value) renderFields();
// Модальное окно заявки (улучшенное)
const modal = document.getElementById('ticketModal');
const modalTicketId = document.getElementById('modalTicketId');
const modalContent = document.getElementById('modalContent');
function closeModal() { modal.style.display = 'none'; }
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.querySelectorAll('.view-ticket').forEach(btn => {
btn.addEventListener('click', async function() {
const ticketId = this.dataset.ticketId;
modal.style.display = 'flex';
modalTicketId.textContent = '#' + ticketId;
modalContent.innerHTML = '<div class="loading-spinner">Загрузка...</div>';
try {
const response = await fetch(`get_ticket_details.php?id=${ticketId}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await response.json();
if (data.error) throw new Error(data.error);
// Формируем красивый HTML
let html = `
<div class="ticket-header">
<div class="ticket-title">${escapeHtml(data.name || 'Заявка')}</div>
<span class="ticket-status status status-${data.status.toLowerCase()}">${escapeHtml(data.status)}</span>
</div>
<div class="info-grid">
<div class="info-label">Приоритет</div><div class="info-value"><span class="priority priority-${data.priority.toLowerCase()}">${escapeHtml(data.priority)}</span></div>
<div class="info-label">Дата создания</div><div class="info-value">${escapeHtml(data.date)}</div>
${data.solvedate ? `<div class="info-label">Дата решения</div><div class="info-value">${escapeHtml(data.solvedate)}</div>` : ''}
</div>
<div class="description-box">
<h4>📝 Описание</h4>
<div>${data.content}</div>
</div>
<h4>💬 Комментарии и ход работ</h4>
<div class="followups-list">
`;
if (data.followups && data.followups.length > 0) {
data.followups.forEach(f => {
html += `
<div class="followup-card">
<div class="followup-header">
<span class="followup-author">${escapeHtml(f.author)}</span>
<span>${escapeHtml(f.date)}</span>
</div>
<div class="followup-content">${escapeHtml(f.content)}</div>
</div>
`;
});
} else {
html += `<div class="no-followups">Комментариев пока нет.</div>`;
}
html += `</div>`;
modalContent.innerHTML = html;
} catch (error) {
modalContent.innerHTML = '<div class="error-message">Не удалось загрузить детали заявки.</div>';
}
});
});
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
</script>
</body>
</html>
+264
View File
@@ -0,0 +1,264 @@
<?php
function checkAuth(PDO $pdo): ?array {
if (!isset($_SESSION['user_id'])) return null;
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
return $user ?: null;
}
function redirect(string $url): void {
header("Location: $url");
exit;
}
function getUserForms(PDO $pdo, int $userId): array {
$stmt = $pdo->prepare("SELECT * FROM forms WHERE user_id = ? ORDER BY created_at DESC");
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
function createLocalForm(PDO $pdo, ?int $userId, string $type, array $data, string $status = 'pending', ?string $priority = null, ?int $locationId = null): int {
$jsonData = json_encode($data, JSON_UNESCAPED_UNICODE);
$stmt = $pdo->prepare("INSERT INTO forms (user_id, type, data, status, priority, location_id) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$userId, $type, $jsonData, $status, $priority, $locationId]);
return (int)$pdo->lastInsertId();
}
function uploadFile(array $file): string|false {
if ($file['error'] !== UPLOAD_ERR_OK) return false;
if ($file['size'] > MAX_FILE_SIZE) return false;
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (!in_array($file['type'], $allowedTypes)) return false;
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$basename = bin2hex(random_bytes(16)) . '.' . $ext;
$destination = UPLOAD_DIR . $basename;
if (move_uploaded_file($file['tmp_name'], $destination)) {
return 'uploads/' . $basename;
}
return false;
}
function syncFormWithGlpi(PDO $pdo, array &$form, GlpiApi $glpi): bool {
$data = json_decode($form['data'], true);
$glpiId = $data['glpi_ticket_id'] ?? null;
if (!$glpiId) return true;
$ticket = $glpi->getTicket($glpiId);
if (!$ticket) {
$stmt = $pdo->prepare("DELETE FROM forms WHERE id = ?");
$stmt->execute([$form['id']]);
return false;
}
$data['glpi_status'] = $ticket['status'] ?? null;
$data['glpi_priority'] = $ticket['priority'] ?? null;
$data['glpi_name'] = $ticket['name'] ?? '';
$newData = json_encode($data, JSON_UNESCAPED_UNICODE);
// ----- МАППИНГ СТАТУСОВ GLPI В ДОПУСТИМЫЕ ЗНАЧЕНИЯ -----
$statusGlpi = strtolower($ticket['status_name'] ?? $ticket['status'] ?? 'unknown');
$statusMap = [
'new' => 'pending',
'incoming' => 'pending',
'waiting' => 'pending',
'processing' => 'processing',
'assigned' => 'processing',
'solved' => 'completed',
'closed' => 'completed',
'rejected' => 'rejected',
'pending' => 'pending'
];
$newStatus = $statusMap[$statusGlpi] ?? 'pending';
// Дополнительная проверка на допустимость
$allowedStatuses = ['pending', 'processing', 'completed', 'rejected'];
if (!in_array($newStatus, $allowedStatuses)) {
$newStatus = 'pending';
}
// ----- МАППИНГ ПРИОРИТЕТОВ (ЧИСЛО -> 'low','medium','high') -----
$priorityGlpi = $ticket['priority'] ?? 3; // число 1..5
$priorityMap = [
1 => 'low',
2 => 'low',
3 => 'medium',
4 => 'high',
5 => 'high'
];
$newPriority = $priorityMap[$priorityGlpi] ?? 'medium';
// Обновляем запись в БД
$stmt = $pdo->prepare("UPDATE forms SET data = ?, status = ?, priority = ? WHERE id = ?");
$stmt->execute([$newData, $newStatus, $newPriority, $form['id']]);
$form['data'] = $newData;
$form['status'] = $newStatus;
$form['priority'] = $newPriority;
return true;
}
/* ========== БАЗА ЗНАНИЙ (исправленная) ========== */
// Получение всех статей
function getAllLocalKnowledgeArticles(PDO $pdo): array {
return $pdo->query("SELECT * FROM knowledge_base ORDER BY updated_at DESC")->fetchAll();
}
// Поиск с FULLTEXT или LIKE
function searchLocalKnowledgeFulltext(PDO $pdo, string $query, int $limit = 30): array {
if (strlen($query) < 3) {
$stmt = $pdo->prepare("SELECT id, title, SUBSTRING(content, 1, 200) AS preview, updated_at FROM knowledge_base WHERE title LIKE :q1 OR content LIKE :q2 ORDER BY updated_at DESC LIMIT $limit");
$like = '%' . $query . '%';
$stmt->execute(['q1' => $like, 'q2' => $like]);
return $stmt->fetchAll();
}
$stmt = $pdo->prepare("SELECT id, title, SUBSTRING(content, 1, 200) AS preview, updated_at, MATCH(title, content) AGAINST(:query IN NATURAL LANGUAGE MODE) AS relevance FROM knowledge_base WHERE MATCH(title, content) AGAINST(:query IN NATURAL LANGUAGE MODE) ORDER BY relevance DESC LIMIT $limit");
$stmt->execute(['query' => $query]);
return $stmt->fetchAll();
}
// Инкрементальная синхронизация (с проверкой существования колонки)
function syncKnowledgeBaseIncremental(PDO $pdo, GlpiApi $glpi, array &$log = []): void {
$log[] = "=== Инкрементальная синхронизация ===";
// Проверяем наличие колонки glpi_updated_at
try {
$pdo->query("SELECT glpi_updated_at FROM knowledge_base LIMIT 1");
} catch (PDOException $e) {
$log[] = "Ошибка: колонка glpi_updated_at отсутствует. Выполните ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME DEFAULT NULL;";
return;
}
$stmt = $pdo->query("SELECT MAX(glpi_updated_at) as last FROM knowledge_base");
$lastSync = $stmt->fetch()['last'];
$since = $lastSync ? $lastSync : '1970-01-01 00:00:00';
$glpiItems = $glpi->searchKnowbaseItemsSince($since);
$log[] = "Найдено изменённых статей в GLPI: " . count($glpiItems);
foreach ($glpiItems as $glpiArticle) {
$glpiId = (int)$glpiArticle['id'];
$fullArticle = $glpi->getKnowbaseItem($glpiId);
if (!$fullArticle) continue;
$title = $fullArticle['name'];
$content = $fullArticle['answer'] ?? '';
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$content = strip_tags($content, '<p><a><img><ul><li><ol><h1><h2><h3><h4><strong><em><span><div><br>');
$glpiUpdatedAt = $fullArticle['date_mod'] ?? date('Y-m-d H:i:s');
// Получаем документы (улучшенным методом)
$docs = $glpi->getKnowbaseItemDocuments($glpiId);
$log[] = "Статья GLPI #$glpiId: найдено документов: " . count($docs);
$documentsMeta = [];
foreach ($docs as $doc) {
$documentsMeta[] = [
'id' => $doc['id'],
'name' => $doc['name'] ?? $doc['filename'] ?? "document_{$doc['id']}",
'mime' => $doc['mime'] ?? '',
'path' => null // путь будет установлен при первом скачивании
];
}
$existing = findLocalKnowledgeByGlpiId($pdo, $glpiId);
if ($existing) {
updateLocalKnowledgeArticleMeta($pdo, $existing['id'], $title, $content, $documentsMeta, $glpiUpdatedAt);
$log[] = "Обновлена статья GLPI #$glpiId";
} else {
insertLocalKnowledgeArticle($pdo, $glpiId, $title, $content, $documentsMeta, $glpiUpdatedAt);
$log[] = "Добавлена новая статья GLPI #$glpiId";
}
}
$log[] = "=== Синхронизация завершена ===";
}
function findLocalKnowledgeByGlpiId(PDO $pdo, int $glpiId): ?array {
$stmt = $pdo->prepare("SELECT * FROM knowledge_base WHERE glpi_id = ?");
$stmt->execute([$glpiId]);
return $stmt->fetch() ?: null;
}
function insertLocalKnowledgeArticle(PDO $pdo, int $glpiId, string $title, string $content, array $documents, string $glpiUpdatedAt): int {
$stmt = $pdo->prepare("INSERT INTO knowledge_base (glpi_id, title, content, documents, glpi_updated_at) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$glpiId, $title, $content, json_encode($documents, JSON_UNESCAPED_UNICODE), $glpiUpdatedAt]);
return (int)$pdo->lastInsertId();
}
function updateLocalKnowledgeArticleMeta(PDO $pdo, int $localId, string $title, string $content, array $documents, string $glpiUpdatedAt): void {
$stmt = $pdo->prepare("SELECT documents FROM knowledge_base WHERE id = ?");
$stmt->execute([$localId]);
$old = $stmt->fetch();
$oldDocs = json_decode($old['documents'], true);
// Сохраняем уже скачанные пути
foreach ($documents as &$newDoc) {
foreach ($oldDocs as $oldDoc) {
if ($oldDoc['id'] == $newDoc['id'] && isset($oldDoc['path'])) {
$newDoc['path'] = $oldDoc['path'];
break;
}
}
}
$update = $pdo->prepare("UPDATE knowledge_base SET title = ?, content = ?, documents = ?, glpi_updated_at = ?, updated_at = NOW() WHERE id = ?");
$update->execute([$title, $content, json_encode($documents, JSON_UNESCAPED_UNICODE), $glpiUpdatedAt, $localId]);
}
function getLocalKnowledgeArticle(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM knowledge_base WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function lazyLoadDocument(PDO $pdo, int $knowledgeId, int $docId, string $docName, string $docMime, GlpiApi $glpi): ?string {
$safeName = $docId . '_' . sanitizeFileName($docName);
$cachePath = KB_CACHE_DIR . $safeName;
if (file_exists($cachePath)) {
return $cachePath;
}
$content = $glpi->getDocumentContent($docId);
if ($content === null) return null;
if (file_put_contents($cachePath, $content)) {
// Обновляем путь в БД
$stmt = $pdo->prepare("SELECT documents FROM knowledge_base WHERE id = ?");
$stmt->execute([$knowledgeId]);
$row = $stmt->fetch();
$docs = json_decode($row['documents'], true);
foreach ($docs as &$d) {
if ($d['id'] == $docId) {
$d['path'] = $safeName;
break;
}
}
$update = $pdo->prepare("UPDATE knowledge_base SET documents = ? WHERE id = ?");
$update->execute([json_encode($docs), $knowledgeId]);
return $cachePath;
}
return null;
}
function sanitizeFileName(string $name): string {
$name = basename($name);
return preg_replace('/[^a-zA-Z0-9_\-\.]/u', '_', $name);
}
function getDocumentUrl(PDO $pdo, int $knowledgeId, int $docId): ?string {
$stmt = $pdo->prepare("SELECT documents FROM knowledge_base WHERE id = ?");
$stmt->execute([$knowledgeId]);
$row = $stmt->fetch();
if (!$row) return null;
$docs = json_decode($row['documents'], true);
foreach ($docs as $doc) {
if ($doc['id'] == $docId) {
if (!empty($doc['path']) && file_exists(KB_CACHE_DIR . $doc['path'])) {
return 'get_document.php?kid=' . $knowledgeId . '&doc=' . $docId;
} else {
// Файл ещё не скачан, но ссылка та же - будет скачан при запросе
return 'get_document.php?kid=' . $knowledgeId . '&doc=' . $docId;
}
}
}
return null;
}
+50
View File
@@ -0,0 +1,50 @@
<?php
ini_set('display_errors', 0);
error_reporting(0);
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) != 'xmlhttprequest') {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
$user = checkAuth($pdo);
if (!$user) {
http_response_code(401);
exit(json_encode(['error' => 'Unauthorized']));
}
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$id) {
exit(json_encode(['error' => 'Invalid ID']));
}
$article = getLocalKnowledgeArticle($pdo, $id);
if (!$article) {
exit(json_encode(['error' => 'Article not found']));
}
$documents = [];
if ($article['documents']) {
$docs = json_decode($article['documents'], true);
if (is_array($docs)) {
foreach ($docs as $doc) {
$documents[] = [
'id' => $doc['id'],
'name' => $doc['name'],
'mime' => $doc['mime'],
'url' => 'get_document.php?kid=' . $id . '&doc=' . $doc['id']
];
}
}
}
echo json_encode([
'id' => $article['id'],
'title' => $article['title'],
'content' => $article['content'],
'documents' => $documents,
'date' => $article['updated_at'] ?? $article['created_at']
]);
exit;
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/glpi_api.php';
header('Content-Type: application/json');
$user = checkAuth($pdo);
if (!$user) { http_response_code(401); exit; }
$computerId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$computerId) { echo json_encode(['location_id' => null]); exit; }
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
$computer = $glpi->getItem('Computer', $computerId);
if ($computer && isset($computer['locations_id'])) {
echo json_encode(['location_id' => $computer['locations_id']]);
} else {
echo json_encode(['location_id' => null]);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/glpi_api.php';
$user = checkAuth($pdo);
if (!$user) {
http_response_code(401);
exit;
}
$kid = isset($_GET['kid']) ? (int)$_GET['kid'] : 0;
$docId = isset($_GET['doc']) ? (int)$_GET['doc'] : 0;
if (!$kid || !$docId) {
http_response_code(400);
exit;
}
$article = getLocalKnowledgeArticle($pdo, $kid);
if (!$article) {
http_response_code(404);
exit;
}
$docs = json_decode($article['documents'], true);
$targetDoc = null;
foreach ($docs as $d) {
if ($d['id'] == $docId) {
$targetDoc = $d;
break;
}
}
if (!$targetDoc) {
http_response_code(403);
exit;
}
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
$filePath = lazyLoadDocument($pdo, $kid, $docId, $targetDoc['name'], $targetDoc['mime'], $glpi);
if (!$filePath || !file_exists($filePath)) {
http_response_code(404);
exit;
}
$mime = mime_content_type($filePath);
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($filePath));
header('Content-Disposition: inline; filename="' . $targetDoc['name'] . '"');
header('Cache-Control: public, max-age=86400');
readfile($filePath);
exit;
+88
View File
@@ -0,0 +1,88 @@
<?php
// Отключаем вывод ошибок в браузер – чтобы JSON был чистым
ini_set('display_errors', 0);
error_reporting(0);
// Логируем ошибки в файл (можно будет посмотреть при необходимости)
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/php-errors.log');
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/glpi_api.php';
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) != 'xmlhttprequest') {
http_response_code(403);
exit('Доступ запрещён');
}
$user = checkAuth($pdo);
if (!$user) {
http_response_code(401);
exit('Unauthorized');
}
$type = $_GET['type'] ?? '';
if (!in_array($type, ['locations', 'computers', 'itilcategories'])) {
http_response_code(400);
exit('Неверный тип данных');
}
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
try {
$items = [];
switch ($type) {
case 'locations':
$items = $glpi->getItems('Location', ['is_deleted' => 0, 'range' => '0-1000']);
break;
case 'computers':
$items = $glpi->getItems('Computer', ['is_deleted' => 0, 'range' => '0-1000']);
// Если GLPI вернул пустой массив, пробуем запросить мониторы или периферию
if (empty($items)) {
$items = $glpi->getItems('Monitor', ['is_deleted' => 0, 'range' => '0-100']);
}
if (empty($items)) {
$items = $glpi->getItems('Peripheral', ['is_deleted' => 0, 'range' => '0-100']);
}
break;
case 'itilcategories':
$items = $glpi->getItems('ITILCategory', ['is_deleted' => 0, 'range' => '0-1000']);
break;
}
$result = [];
if (is_array($items)) {
foreach ($items as $item) {
$id = $item['id'] ?? null;
$name = $item['name'] ?? $item['completename'] ?? null;
if ($id && $name) {
// Для компьютеров добавляем инвентарный номер (otherserial или serial)
if ($type === 'computers') {
$inventory = $item['otherserial'] ?? $item['serial'] ?? '';
if (!empty($inventory)) {
$name .= ' (Инв.№: ' . htmlspecialchars($inventory) . ')';
}
}
$result[] = ['id' => $id, 'name' => $name];
}
}
}
// Fallback для компьютеров – если данных нет, отдаём статический список
if ($type === 'computers' && empty($result)) {
$result = [
['id' => 'pc', 'name' => 'Персональный компьютер'],
['id' => 'laptop', 'name' => 'Ноутбук'],
['id' => 'printer', 'name' => 'Принтер / МФУ'],
['id' => 'network', 'name' => 'Сетевое оборудование'],
['id' => 'other', 'name' => 'Другое'],
];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($result);
} catch (Exception $e) {
error_log("GLPI data fetch error ($type): " . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
exit;
+102
View File
@@ -0,0 +1,102 @@
<?php
ini_set('display_errors', 0);
error_reporting(0);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/glpi_api.php';
header('Content-Type: application/json; charset=utf-8');
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) != 'xmlhttprequest') {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
$user = checkAuth($pdo);
if (!$user) {
http_response_code(401);
exit(json_encode(['error' => 'Unauthorized']));
}
$ticketId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$ticketId) {
exit(json_encode(['error' => 'Invalid ticket ID']));
}
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
$ticket = $glpi->getTicket($ticketId);
if (!$ticket) {
exit(json_encode(['error' => 'Unable to fetch ticket from GLPI']));
}
// Маппинг статусов GLPI (числовые -> русские названия)
$statusMap = [
1 => 'Новый',
2 => 'В обработке',
3 => 'Закрыт',
4 => 'Решён',
5 => 'Отложен',
6 => 'Ожидает',
];
$priorityMap = [
1 => 'Низкий',
2 => 'Средний',
3 => 'Высокий',
4 => 'Срочный',
5 => 'Критический',
];
$statusName = $ticket['status_name'] ?? ($statusMap[$ticket['status']] ?? 'Неизвестно');
$priorityName = $ticket['priority_name'] ?? ($priorityMap[$ticket['priority']] ?? 'Неизвестно');
// Парсим содержимое для получения читаемого описания (без ID)
$contentText = $ticket['content'] ?? '';
$locationName = '';
$computerName = '';
$categoryName = '';
if (preg_match('/ID локации:\s*(\d+)/', $contentText, $m)) {
$loc = $glpi->getItem('Location', (int)$m[1]);
if ($loc) $locationName = $loc['completename'] ?? $loc['name'] ?? '';
}
if (preg_match('/ID компьютера:\s*(\d+)/', $contentText, $m)) {
$comp = $glpi->getItem('Computer', (int)$m[1]);
if ($comp) $computerName = $comp['name'] ?? '';
}
if (preg_match('/ID категории:\s*(\d+)/', $contentText, $m)) {
$cat = $glpi->getItem('ITILCategory', (int)$m[1]);
if ($cat) $categoryName = $cat['completename'] ?? $cat['name'] ?? '';
}
// Чистое описание (убираем строки с ID)
$cleanContent = preg_replace('/ID (локации|компьютера|категории):\s*\d+\s*/', '', $contentText);
$cleanContent = trim(preg_replace('/Описание:\s*/', '', $cleanContent));
$readableContentHtml = nl2br(htmlspecialchars($cleanContent));
// Комментарии (followups)
$followups = $glpi->getTicketFollowups($ticketId);
$processedFollowups = [];
foreach ($followups as $f) {
$processedFollowups[] = [
'date' => date('d.m.Y H:i', strtotime($f['date'] ?? $f['date_creation'] ?? 'now')),
'content' => nl2br(htmlspecialchars($f['content'] ?? '')),
'author' => htmlspecialchars($f['users_id_name'] ?? ($f['users_name'] ?? 'Система'))
];
}
$result = [
'id' => $ticket['id'],
'name' => $ticket['name'],
'content' => $readableContentHtml,
'status' => $statusName,
'priority' => $priorityName,
'date' => date('d.m.Y H:i', strtotime($ticket['date'] ?? $ticket['date_creation'])),
'solvedate' => isset($ticket['solvedate']) ? date('d.m.Y H:i', strtotime($ticket['solvedate'])) : null,
'location' => $locationName,
'computer' => $computerName,
'category' => $categoryName,
'followups' => $processedFollowups
];
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
+388
View File
@@ -0,0 +1,388 @@
<?php
class GlpiApi {
private string $url;
private string $appToken;
private string $username;
private string $password;
private ?string $sessionToken = null;
public function __construct(string $url, string $appToken, string $username, string $password) {
$this->url = rtrim($url, '/');
$this->appToken = $appToken;
$this->username = $username;
$this->password = $password;
}
private function log(string $message) {
error_log('[GLPI API] ' . $message);
}
public function getSessionToken(): ?string {
return $this->sessionToken;
}
public function initSession(): bool {
$endpoint = $this->url . '/initSession';
$ch = curl_init($endpoint);
$auth = base64_encode($this->username . ':' . $this->password);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Authorization: Basic ' . $auth,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$data = json_decode($response, true);
$this->sessionToken = $data['session_token'] ?? null;
return true;
}
return false;
}
public function getItems(string $itemType, array $params = []): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$endpoint = $this->url . '/' . $itemType;
if (!empty($params)) {
$endpoint .= '?' . http_build_query($params);
}
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true) ?? [];
}
$this->log("getItems($itemType) HTTP $httpCode");
return [];
}
public function getItem(string $itemType, int $id): ?array {
if (!$this->sessionToken && !$this->initSession()) return null;
$endpoint = $this->url . '/' . $itemType . '/' . $id;
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true);
}
$this->log("getItem($itemType/$id) HTTP $httpCode");
return null;
}
public function createTicket(array $ticketData): ?array {
if (!$this->sessionToken && !$this->initSession()) return null;
$endpoint = $this->url . '/Ticket';
$ch = curl_init($endpoint);
$payload = json_encode(['input' => $ticketData]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 201) {
return json_decode($response, true);
}
$this->log("createTicket HTTP $httpCode: " . substr($response, 0, 500));
return null;
}
public function getTicket(int $id): ?array {
if (!$this->sessionToken && !$this->initSession()) return null;
$endpoint = $this->url . '/Ticket/' . $id . '?expand_dropdowns=true';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true);
}
$this->log("getTicket($id) HTTP $httpCode");
return null;
}
public function getTicketFollowups(int $ticketId): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$endpoint = $this->url . '/Ticket/' . $ticketId . '/ITILFollowup?expand_dropdowns=true&range=0-100';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true) ?? [];
}
$this->log("getTicketFollowups($ticketId) HTTP $httpCode");
return [];
}
public function getKnowbaseItems(int $start = 0, int $limit = 100): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$params = ['range' => $start . '-' . ($start + $limit - 1), 'expand_dropdowns' => 'true'];
$endpoint = $this->url . '/KnowbaseItem?' . http_build_query($params);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true) ?? [];
}
return [];
}
public function getKnowbaseItem(int $id): ?array {
if (!$this->sessionToken && !$this->initSession()) return null;
$endpoint = $this->url . '/KnowbaseItem/' . $id . '?expand_dropdowns=true';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
return json_decode($response, true);
}
return null;
}
public function searchKnowbaseItems(string $query, int $limit = 30): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$endpoint = $this->url . '/search/KnowbaseItem';
$criteria = [
'criteria' => [
['link' => 'OR', 'field' => 1, 'searchtype' => 'contains', 'value' => $query],
['link' => 'OR', 'field' => 2, 'searchtype' => 'contains', 'value' => $query]
],
'range' => "0-$limit",
'sort' => 2,
'order' => 'DESC'
];
$payload = json_encode($criteria);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$data = json_decode($response, true);
return $data['data'] ?? [];
}
$this->log("searchKnowbaseItems HTTP $httpCode");
return [];
}
public function searchKnowbaseItemsSince(string $since, int $limit = 1000): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$endpoint = $this->url . '/search/KnowbaseItem';
$criteria = [
'criteria' => [
['field' => 15, 'searchtype' => 'morethan', 'value' => $since] // 15 = date_mod
],
'range' => "0-$limit",
'sort' => 15,
'order' => 'ASC'
];
$payload = json_encode($criteria);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$data = json_decode($response, true);
return $data['data'] ?? [];
}
$this->log("searchKnowbaseItemsSince HTTP $httpCode");
return [];
}
/**
* Получение документов, привязанных к статье базы знаний (улучшенная версия)
*/
public function getKnowbaseItemDocuments(int $kbItemId): array {
if (!$this->sessionToken && !$this->initSession()) return [];
$documents = [];
// Способ 1: через прямой фильтр Document
$params = [
'filter[itemtype]' => 'KnowbaseItem',
'filter[items_id]' => $kbItemId,
'range' => '0-100'
];
$endpoint = $this->url . '/Document?' . http_build_query($params);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$docs = json_decode($response, true);
if (is_array($docs) && count($docs) > 0) {
$this->log("Got " . count($docs) . " documents for KB $kbItemId via Document filter");
return $docs;
}
}
// Способ 2: через подресурс KnowbaseItem/{id}/Document
$endpoint = $this->url . '/KnowbaseItem/' . $kbItemId . '/Document?expand_dropdowns=true&range=0-100';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$docs = json_decode($response, true);
if (is_array($docs) && count($docs) > 0) {
$this->log("Got " . count($docs) . " documents for KB $kbItemId via subresource");
return $docs;
}
}
// Способ 3: через Document_Item (если GLPI версии 10+)
$endpoint = $this->url . '/Document_Item?filter[itemtype]=KnowbaseItem&filter[items_id]=' . $kbItemId;
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
$links = json_decode($response, true);
if (is_array($links) && count($links) > 0) {
// Извлекаем ID документов
$docIds = array_column($links, 'documents_id');
if (!empty($docIds)) {
$ids = implode(',', $docIds);
$endpoint2 = $this->url . '/Document?filter[id]=' . $ids . '&range=0-100';
$ch2 = curl_init($endpoint2);
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$resp2 = curl_exec($ch2);
if (curl_getinfo($ch2, CURLINFO_HTTP_CODE) === 200) {
$docs = json_decode($resp2, true);
$this->log("Got " . count($docs) . " documents for KB $kbItemId via Document_Item");
return $docs;
}
}
}
}
$this->log("No documents found for KB $kbItemId (tried all methods)");
return [];
}
public function getDocumentContent(int $documentId): ?string {
if (!$this->sessionToken && !$this->initSession()) return null;
$endpoint = $this->url . '/Document/' . $documentId . '?download=true';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'App-Token: ' . $this->appToken,
'Session-Token: ' . $this->sessionToken,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode !== 200 || $response === false) {
$this->log("getDocumentContent($documentId) failed HTTP $httpCode");
return null;
}
$json = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $response;
}
if (isset($json['content'])) {
$data = base64_decode($json['content'], true);
if ($data !== false) {
return $data;
}
return $json['content'];
}
$this->log("getDocumentContent($documentId) returned unexpected JSON");
return null;
}
/**
* Получить имя компьютера по ID
*/
public function getComputerName(int $computerId): string {
$computer = $this->getItem('Computer', $computerId);
if ($computer) {
$name = $computer['name'] ?? '';
$serial = $computer['serial'] ?? $computer['otherserial'] ?? '';
if ($serial) {
return "$name (Инв.№ $serial)";
}
return $name;
}
return "Компьютер #$computerId";
}
/**
* Получить полное имя категории ITIL по ID
*/
public function getCategoryName(int $categoryId): string {
$category = $this->getItem('ITILCategory', $categoryId);
if ($category) {
return $category['completename'] ?? $category['name'] ?? "Категория #$categoryId";
}
return "Категория #$categoryId";
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/config.php';
// Если пользователь уже авторизован, перенаправляем на dashboard
$user = checkAuth($pdo);
if ($user) {
redirect('dashboard.php');
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if ($username && $password) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
// Проверка пароля (предполагается хеширование password_hash)
if ($user && password_verify($password, $user['password'])) {
$_SESSION['user_id'] = $user['id'];
redirect('dashboard.php');
} else {
$error = 'Неверное имя пользователя или пароль';
}
} else {
$error = 'Заполните все поля';
}
}
?>
<!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 class="login-page">
<div class="login-container">
<h1 class="centreee" >ВХОД В СИСТЕМУ</h1>
<?php if ($error): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post">
<div class="form-group">
<label for="username">Логин</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn-primary logbt">Войти</button>
</form>
</div>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<?php
require_once __DIR__ . '/config.php';
$user = checkAuth($pdo);
if (!$user) {
redirect('index.php');
}
require_once __DIR__ . '/glpi_api.php';
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
// Ручная синхронизация (только для админа)
if (isset($_GET['sync']) && $_GET['sync'] === '1' && ($user['role'] === 'admin' || $user['role'] === 'superadmin')) {
$log = [];
syncKnowledgeBaseIncremental($pdo, $glpi, $log);
header('Location: knowledge.php?synced=1');
exit;
}
$synced = isset($_GET['synced']);
// Сортировка: по дате или по названию
$order = $_GET['order'] ?? 'date';
$allowedOrders = ['date', 'title'];
if (!in_array($order, $allowedOrders)) $order = 'date';
$sqlOrder = ($order === 'date') ? 'updated_at DESC' : 'title ASC';
$stmt = $pdo->prepare("SELECT * FROM knowledge_base ORDER BY $sqlOrder");
$stmt->execute();
$articles = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>База знаний Service Desk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="header containers">
<div class="container">
<h1>🧙‍♂️ Чинилыч</h1>
<div class="user-info">
<span><?= htmlspecialchars($user['last_name'] . ' ' . $user['first_name']) ?></span>
<button id="themeToggle" class="theme-toggle" aria-label="Тёмная тема">🌙</button>
<a href="logout.php" class="btn btn-outline">Выход</a>
</div>
</div>
</header>
<main class="container">
<div class="nav-links">
<a href="dashboard.php">📋 Заявки</a>
<a href="knowledge.php" class="active">📚 База знаний</a>
</div>
<?php if ($synced): ?>
<div class="alert alert-success">Синхронизация успешно завершена.</div>
<?php endif; ?>
<div class="card">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Поиск по базе знаний...">
<button class="btn btn-primary" id="searchBtn">Найти</button>
<button class="btn btn-outline" id="resetSearchBtn">Сбросить</button>
<div style="flex:1"></div>
<div class="sort-buttons">
<a href="?order=date" class="btn btn-outline <?= $order === 'date' ? 'active' : '' ?>">📅 По дате</a>
<a href="?order=title" class="btn btn-outline <?= $order === 'title' ? 'active' : '' ?>">🔤 По названию</a>
</div>
<?php if ($user['role'] === 'admin' || $user['role'] === 'superadmin'): ?>
<a href="?sync=1" class="btn btn-outline">🔄 Обновить базу</a>
<?php endif; ?>
</div>
</div>
<div class="card">
<h2>📚 Статьи</h2>
<div id="articlesList">
<?php if (empty($articles)): ?>
<p>Статьи не найдены. Нажмите "Обновить базу", чтобы загрузить статьи из GLPI.</p>
<?php else: ?>
<?php foreach ($articles as $item):
$preview = mb_substr(strip_tags($item['content']), 0, 200) . '...';
?>
<div class="article-card" data-id="<?= $item['id'] ?>">
<div class="article-title"><?= htmlspecialchars($item['title']) ?></div>
<div class="article-meta">Обновлено: <?= date('d.m.Y', strtotime($item['updated_at'] ?? $item['created_at'])) ?></div>
<div class="article-preview"><?= htmlspecialchars($preview) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</main>
<!-- Модальное окно статьи -->
<div class="modal-overlay" id="articleModal">
<div class="modal modal-large">
<div class="modal-header">
<h3 id="modalTitle"></h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalContent">
<div class="loading-spinner">Загрузка...</div>
</div>
</div>
</div>
<style>
.modal-large { max-width: 900px; width: 95%; }
.documents-list { margin-top: 20px; border-top: 1px solid var(--border-light); padding-top: 15px; }
.documents-list h4 { margin-bottom: 12px; }
.doc-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-light); }
.doc-icon { font-size: 1.4rem; }
.doc-name { flex: 1; word-break: break-all; }
.doc-link { color: var(--primary-dark); text-decoration: none; }
.doc-link:hover { text-decoration: underline; }
.sort-buttons { display: flex; gap: 8px; }
.sort-buttons .btn.active { background: var(--primary); color: white; border-color: var(--primary); }
@media (max-width: 640px) {
.search-box { flex-direction: column; }
.sort-buttons { justify-content: center; }
}
</style>
<script>
const themeToggle = document.getElementById('themeToggle');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark');
localStorage.setItem('theme', document.body.classList.contains('dark') ? 'dark' : 'light');
themeToggle.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
});
const modal = document.getElementById('articleModal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalContent');
function closeModal() { modal.style.display = 'none'; }
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.addEventListener('click', async function(e) {
const card = e.target.closest('.article-card');
if (!card) return;
const articleId = card.dataset.id;
modal.style.display = 'flex';
modalTitle.textContent = 'Загрузка...';
modalContent.innerHTML = '<div class="loading-spinner">Загрузка статьи...</div>';
try {
const response = await fetch(`get_article.php?id=${articleId}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) throw new Error(data.error);
modalTitle.textContent = data.title;
let html = `<div class="article-content">${data.content || 'Нет текста'}</div>`;
if (data.documents && data.documents.length > 0) {
html += `<div class="documents-list"><h4>📎 Прикреплённые файлы</h4>`;
data.documents.forEach(doc => {
const icon = doc.mime.startsWith('image/') ? '🖼️' : (doc.mime === 'application/pdf' ? '📄' : '📎');
html += `
<div class="doc-item">
<span class="doc-icon">${icon}</span>
<span class="doc-name">${escapeHtml(doc.name)}</span>
<a href="${doc.url}" class="doc-link" target="_blank">Скачать</a>
</div>`;
});
html += `</div>`;
}
modalContent.innerHTML = html;
} catch (error) {
modalContent.innerHTML = '<div class="error-message">Не удалось загрузить статью.</div>';
modalTitle.textContent = 'Ошибка';
}
});
function escapeHtml(str) {
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
const searchBtn = document.getElementById('searchBtn');
const resetBtn = document.getElementById('resetSearchBtn');
const searchInput = document.getElementById('searchInput');
async function performSearch() {
const query = searchInput.value.trim();
if (!query) { resetSearch(); return; }
const articlesDiv = document.getElementById('articlesList');
articlesDiv.innerHTML = '<div class="loading-spinner">Поиск...</div>';
try {
const response = await fetch(`search_knowledge.php?q=${encodeURIComponent(query)}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.length === 0) {
articlesDiv.innerHTML = '<div class="no-results">Ничего не найдено.</div>';
return;
}
let html = '';
data.forEach(item => {
html += `<div class="article-card" data-id="${item.id}">
<div class="article-title">${escapeHtml(item.title)}</div>
<div class="article-meta">Обновлено: ${item.updated_at}</div>
<div class="article-preview">${escapeHtml(item.preview)}</div>
</div>`;
});
articlesDiv.innerHTML = html;
} catch(e) {
articlesDiv.innerHTML = '<div class="error-message">Ошибка поиска.</div>';
}
}
function resetSearch() { searchInput.value = ''; location.reload(); }
searchBtn.addEventListener('click', performSearch);
resetBtn.addEventListener('click', resetSearch);
searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(); });
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<?php
session_start();
session_destroy();
header('Location: index.php');
exit;
+37
View File
@@ -0,0 +1,37 @@
<?php
ini_set('display_errors', 0);
error_reporting(0);
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) != 'xmlhttprequest') {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
$user = checkAuth($pdo);
if (!$user) {
http_response_code(401);
exit(json_encode(['error' => 'Unauthorized']));
}
$query = trim($_GET['q'] ?? '');
if (strlen($query) < 2) {
exit(json_encode([]));
}
// Используем полнотекстовый поиск
$results = searchLocalKnowledgeFulltext($pdo, $query, 50);
$output = [];
foreach ($results as $row) {
$output[] = [
'id' => $row['id'],
'title' => htmlspecialchars($row['title']),
'preview' => htmlspecialchars($row['preview']),
'updated_at' => date('d.m.Y', strtotime($row['updated_at']))
];
}
echo json_encode($output);
exit;
+738
View File
@@ -0,0 +1,738 @@
:root {
--primary: #78909c;
--primary-light: #b0bec5;
--primary-dark: #546e7a;
--bg-light: #f8fafc;
--bg-white: #ffffff;
--text-dark: #37474f;
--text-muted: #607d8b;
--border-light: #e2e8f0;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--border-radius: 12px;
--border-radius-sm: 8px;
--transition: all 0.2s ease;
--card-bg: #ffffff;
--header-bg: #78909c;
--input-bg: #ffffff;
}
body.dark {
--primary: #90a4ae;
--primary-light: #b0bec5;
--primary-dark: #cfd8dc;
--bg-light: #121212;
--bg-white: #1e1e1e;
--text-dark: #e0e0e0;
--text-muted: #9e9e9e;
--border-light: #333333;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--card-bg: #2d2d2d;
--header-bg: #78909c;
--input-bg: #2d2d2d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-light);
color: var(--text-dark);
line-height: 1.6;
transition: background-color 0.3s, color 0.2s;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 24px;
}
.containers {
max-width: 1232px;
margin: 0 auto;
border-radius: 10px;
margin-top: 10px;
}
/* Страница входа */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(145deg, #eef2f6 0%, #d9e2e9 100%);
}
body.dark .login-page {
background: linear-gradient(145deg, #1a1a1a 0%, #0a0a0a 100%);
}
.login-container {
background: var(--bg-white);
padding: 48px 40px;
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 420px;
}
.centreee {
text-align: center;
margin-bottom: 10px;
}
.logbt{
width: 100%;
border: none;
border-radius: 10px;;
display: inline-block;
padding: 12px 28px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
}
/* Шапка */
.header {
background: var(--header-bg);
padding: 20px;
margin-bottom: 32px;
}
.header .container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.header h1 {
font-size: 1.8rem;
color: #ffffff;
font-weight: 500;
}
.user-info {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
color: #ffffff;
}
.theme-toggle {
background: #566973;
border-radius: 40px;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 1.2rem;
transition: var(--transition);
}
.theme-toggle:hover {
background: #37444a;
}
/* Карточки */
.card {
background: var(--card-bg);
border-radius: var(--border-radius);
padding: 32px;
margin-bottom: 28px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.card h2 {
margin-bottom: 24px;
color: var(--primary-dark);
font-weight: 500;
font-size: 1.5rem;
}
.card h3 {
margin: 20px 0 12px;
color: var(--text-dark);
font-weight: 500;
font-size: 1.25rem;
}
/* Пояснительный блок */
.info-block {
background: #f1f5f9;
border-left: 4px solid var(--primary);
padding: 20px 24px;
border-radius: var(--border-radius-sm);
margin-bottom: 28px;
}
body.dark .info-block {
background: #2a2a2a;
}
/* FAQ (аккордеон) */
.faq-item {
border-bottom: 1px solid var(--border-light);
}
.faq-question {
display: block;
padding: 16px 0;
font-weight: 500;
color: var(--primary-dark);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.faq-question:hover {
color: var(--primary);
}
.faq-question::after {
content: "▼";
float: right;
font-size: 12px;
color: var(--primary-light);
transition: transform 0.2s;
}
.faq-toggle {
display: none;
}
.faq-answer {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
padding: 0 0 0 0;
}
.faq-toggle:checked + .faq-question::after {
transform: rotate(180deg);
}
.faq-toggle:checked + .faq-question + .faq-answer {
max-height: 200px;
padding: 0 0 16px 0;
}
.faq-answer p {
color: var(--text-muted);
margin-bottom: 12px;
}
/* Ссылки-кнопки */
.external-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.btn-link {
background: var(--bg-light);
border: 1px solid var(--border-light);
padding: 12px 24px;
border-radius: 40px;
color: var(--primary-dark);
text-decoration: none;
font-weight: 500;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-link:hover {
background: var(--primary-light);
border-color: var(--primary);
color: var(--bg-white);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
/* Формы */
.form-group {
margin-bottom: 24px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-dark);
}
input, select, textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-light);
border-radius: var(--border-radius-sm);
font-size: 1rem;
transition: var(--transition);
background: var(--input-bg);
color: var(--text-dark);
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(120, 144, 156, 0.15);
}
/* Кнопки */
.btn {
display: inline-block;
padding: 12px 28px;
border: none;
border-radius: 40px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: var(--transition);
}
.btn-primary {
background: var(--primary);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.btn-outline {
background: #566973;
color: #ffffff;
}
.btn-outline:hover {
background: #37444a;
border-color: var(--primary);
}
/* Таблицы */
.table-wrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
min-width: 500px;
}
.table th {
text-align: left;
padding: 16px 12px;
border-bottom: 2px solid var(--primary-light);
font-weight: 600;
color: var(--primary-dark);
}
.table td {
padding: 14px 12px;
border-bottom: 1px solid var(--border-light);
}
/* Статусы и приоритеты */
.priority, .status {
display: inline-block;
padding: 6px 14px;
border-radius: 30px;
font-size: 0.8rem;
font-weight: 500;
}
.priority-low {
background: #e8f0e8;
color: #2e6b2e;
}
.priority-medium {
background: #fff3e0;
color: #b85c00;
}
.priority-high {
background: #fce4e4;
color: #b71c1c;
}
.priority-critical {
background: #b71c1c;
color: white;
}
.status-pending {
background: #fef9e6;
color: #9e7e00;
}
.status-processing {
background: #e3f0f5;
color: #1e637b;
}
.status-completed {
background: #e6f3e6;
color: #2a6b2a;
}
.status-rejected {
background: #fbeaec;
color: #b71c1c;
}
/* Алерты */
.alert {
padding: 16px 20px;
border-radius: var(--border-radius-sm);
margin-bottom: 24px;
border-left: 5px solid;
background: var(--bg-white);
box-shadow: var(--shadow-sm);
}
.alert-success {
border-left-color: #2e7d32;
background: #edf7ed;
color: #1b5e20;
}
body.dark .alert-success {
background: #1e3a1e;
color: #81c784;
}
.alert-danger {
border-left-color: #c62828;
background: #fdeded;
color: #b71c1c;
}
body.dark .alert-danger {
background: #3a1e1e;
color: #ef9a9a;
}
.alert-warning {
border-left-color: #ed6c02;
background: #fff4e5;
color: #663c00;
}
body.dark .alert-warning {
background: #3a2e1e;
color: #ffb74d;
}
/* Поле для файла */
input[type="file"] {
padding: 10px 0;
border: none;
background: transparent;
}
.hint {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 6px;
}
.field-group {
background: #fafbfc;
border-radius: var(--border-radius-sm);
padding: 24px;
margin-bottom: 20px;
border: 1px solid #e9eef2;
}
body.dark .field-group {
background: #252525;
border-color: #444;
}
/* Навигация */
.nav-links {
display: flex;
gap: 30px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border-light);
padding-bottom: 12px;
flex-wrap: wrap;
}
.nav-links a {
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
padding: 8px 0;
border-bottom: 2px solid transparent;
transition: var(--transition);
}
.nav-links a:hover,
.nav-links a.active {
color: var(--primary-dark);
border-bottom-color: var(--primary);
}
/* Модальное окно */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--card-bg);
border-radius: 16px;
max-width: 700px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 { margin: 0; color: var(--primary-dark); }
.modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-muted);
}
.modal-body { padding: 24px; }
.ticket-detail-row {
display: flex;
margin-bottom: 12px;
}
.ticket-detail-label {
width: 120px;
font-weight: 500;
color: var(--text-muted);
}
.ticket-detail-value { flex: 1; }
.followup-item {
background: var(--bg-light);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.followup-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 0.9rem;
color: var(--text-muted);
}
.loading-spinner {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.btn-sm {
padding: 6px 12px;
font-size: 0.9rem;
}
/* Страница знаний */
.search-box {
display: flex;
gap: 10px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.search-box input {
flex: 1;
padding: 14px 20px;
font-size: 1.1rem;
}
.article-card {
background: var(--bg-white);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border-light);
transition: var(--transition);
cursor: pointer;
}
.article-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--primary-light);
}
.article-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--primary-dark);
margin-bottom: 8px;
}
.article-meta {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.article-preview {
color: var(--text-dark);
line-height: 1.5;
}
.article-content {
line-height: 1.7;
}
.article-content img {
max-width: 100%;
height: auto;
}
.documents-list {
margin-top: 20px;
}
.doc-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.no-results {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Адаптивность */
@media (max-width: 640px) {
.container { padding: 0 16px; }
.header .container { flex-direction: column; gap: 12px; text-align: center; }
.external-links { flex-direction: column; }
.btn-link { justify-content: center; }
.card { padding: 20px; }
.search-box { flex-direction: column; }
.search-box button { width: 100%; }
.modal { width: 95%; margin: 10px; }
.ticket-detail-row { flex-direction: column; }
.ticket-detail-label { width: auto; margin-bottom: 4px; }
}
/* Документы в базе знаний */
.documents-list {
margin-top: 20px;
border-top: 1px solid var(--border-light);
padding-top: 15px;
}
.documents-list h4 {
margin-bottom: 12px;
}
.doc-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-light);
}
.doc-icon {
font-size: 1.4rem;
}
.doc-name {
flex: 1;
word-break: break-all;
}
.doc-link {
color: var(--primary-dark);
text-decoration: none;
}
.doc-link:hover {
text-decoration: underline;
}
.sort-buttons {
display: flex;
gap: 8px;
}
.sort-buttons .btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Стили для улучшенного модального окна */
.modal-large {
max-width: 700px;
width: 90%;
}
.modal-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid var(--border-light);
margin-bottom: 20px;
padding-bottom: 8px;
}
.tab-btn {
background: none;
border: none;
padding: 8px 20px;
font-size: 1rem;
cursor: pointer;
color: var(--text-muted);
border-radius: 20px;
transition: var(--transition);
}
.tab-btn.active {
background: var(--primary);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.detail-card {
background: var(--bg-light);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.detail-row {
display: flex;
margin-bottom: 12px;
flex-wrap: wrap;
}
.detail-label {
width: 130px;
font-weight: 500;
color: var(--text-muted);
}
.detail-value {
flex: 1;
}
.comment-item {
background: var(--bg-light);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 0.85rem;
color: var(--text-muted);
border-bottom: 1px solid var(--border-light);
padding-bottom: 8px;
}
.comment-author {
font-weight: 600;
color: var(--primary-dark);
}
.comment-content {
line-height: 1.5;
}
@media (max-width: 640px) {
.detail-label { width: 100%; margin-bottom: 4px; }
.modal-tabs { justify-content: center; }
}