227 lines
11 KiB
PHP
227 lines
11 KiB
PHP
<?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>
|