Первоначальная версия проекта chinilich-1.0
This commit is contained in:
@@ -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` (сейчас синхронизация только по кнопке) |
|
||||
@@ -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
@@ -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
@@ -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()">×</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 '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+264
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()">×</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 '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
session_start();
|
||||
session_destroy();
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user