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

This commit is contained in:
2026-05-10 16:49:04 +03:00
commit 9b598697c5
16 changed files with 2886 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
<?php
require_once __DIR__ . '/config.php';
$user = checkAuth($pdo);
if (!$user) {
redirect('index.php');
}
require_once __DIR__ . '/glpi_api.php';
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
// Ручная синхронизация (только для админа)
if (isset($_GET['sync']) && $_GET['sync'] === '1' && ($user['role'] === 'admin' || $user['role'] === 'superadmin')) {
$log = [];
syncKnowledgeBaseIncremental($pdo, $glpi, $log);
header('Location: knowledge.php?synced=1');
exit;
}
$synced = isset($_GET['synced']);
// Сортировка: по дате или по названию
$order = $_GET['order'] ?? 'date';
$allowedOrders = ['date', 'title'];
if (!in_array($order, $allowedOrders)) $order = 'date';
$sqlOrder = ($order === 'date') ? 'updated_at DESC' : 'title ASC';
$stmt = $pdo->prepare("SELECT * FROM knowledge_base ORDER BY $sqlOrder");
$stmt->execute();
$articles = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>База знаний Service Desk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="header containers">
<div class="container">
<h1>🧙‍♂️ Чинилыч</h1>
<div class="user-info">
<span><?= htmlspecialchars($user['last_name'] . ' ' . $user['first_name']) ?></span>
<button id="themeToggle" class="theme-toggle" aria-label="Тёмная тема">🌙</button>
<a href="logout.php" class="btn btn-outline">Выход</a>
</div>
</div>
</header>
<main class="container">
<div class="nav-links">
<a href="dashboard.php">📋 Заявки</a>
<a href="knowledge.php" class="active">📚 База знаний</a>
</div>
<?php if ($synced): ?>
<div class="alert alert-success">Синхронизация успешно завершена.</div>
<?php endif; ?>
<div class="card">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Поиск по базе знаний...">
<button class="btn btn-primary" id="searchBtn">Найти</button>
<button class="btn btn-outline" id="resetSearchBtn">Сбросить</button>
<div style="flex:1"></div>
<div class="sort-buttons">
<a href="?order=date" class="btn btn-outline <?= $order === 'date' ? 'active' : '' ?>">📅 По дате</a>
<a href="?order=title" class="btn btn-outline <?= $order === 'title' ? 'active' : '' ?>">🔤 По названию</a>
</div>
<?php if ($user['role'] === 'admin' || $user['role'] === 'superadmin'): ?>
<a href="?sync=1" class="btn btn-outline">🔄 Обновить базу</a>
<?php endif; ?>
</div>
</div>
<div class="card">
<h2>📚 Статьи</h2>
<div id="articlesList">
<?php if (empty($articles)): ?>
<p>Статьи не найдены. Нажмите "Обновить базу", чтобы загрузить статьи из GLPI.</p>
<?php else: ?>
<?php foreach ($articles as $item):
$preview = mb_substr(strip_tags($item['content']), 0, 200) . '...';
?>
<div class="article-card" data-id="<?= $item['id'] ?>">
<div class="article-title"><?= htmlspecialchars($item['title']) ?></div>
<div class="article-meta">Обновлено: <?= date('d.m.Y', strtotime($item['updated_at'] ?? $item['created_at'])) ?></div>
<div class="article-preview"><?= htmlspecialchars($preview) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</main>
<!-- Модальное окно статьи -->
<div class="modal-overlay" id="articleModal">
<div class="modal modal-large">
<div class="modal-header">
<h3 id="modalTitle"></h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalContent">
<div class="loading-spinner">Загрузка...</div>
</div>
</div>
</div>
<style>
.modal-large { max-width: 900px; width: 95%; }
.documents-list { margin-top: 20px; border-top: 1px solid var(--border-light); padding-top: 15px; }
.documents-list h4 { margin-bottom: 12px; }
.doc-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-light); }
.doc-icon { font-size: 1.4rem; }
.doc-name { flex: 1; word-break: break-all; }
.doc-link { color: var(--primary-dark); text-decoration: none; }
.doc-link:hover { text-decoration: underline; }
.sort-buttons { display: flex; gap: 8px; }
.sort-buttons .btn.active { background: var(--primary); color: white; border-color: var(--primary); }
@media (max-width: 640px) {
.search-box { flex-direction: column; }
.sort-buttons { justify-content: center; }
}
</style>
<script>
const themeToggle = document.getElementById('themeToggle');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark');
localStorage.setItem('theme', document.body.classList.contains('dark') ? 'dark' : 'light');
themeToggle.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
});
const modal = document.getElementById('articleModal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalContent');
function closeModal() { modal.style.display = 'none'; }
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.addEventListener('click', async function(e) {
const card = e.target.closest('.article-card');
if (!card) return;
const articleId = card.dataset.id;
modal.style.display = 'flex';
modalTitle.textContent = 'Загрузка...';
modalContent.innerHTML = '<div class="loading-spinner">Загрузка статьи...</div>';
try {
const response = await fetch(`get_article.php?id=${articleId}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) throw new Error(data.error);
modalTitle.textContent = data.title;
let html = `<div class="article-content">${data.content || 'Нет текста'}</div>`;
if (data.documents && data.documents.length > 0) {
html += `<div class="documents-list"><h4>📎 Прикреплённые файлы</h4>`;
data.documents.forEach(doc => {
const icon = doc.mime.startsWith('image/') ? '🖼️' : (doc.mime === 'application/pdf' ? '📄' : '📎');
html += `
<div class="doc-item">
<span class="doc-icon">${icon}</span>
<span class="doc-name">${escapeHtml(doc.name)}</span>
<a href="${doc.url}" class="doc-link" target="_blank">Скачать</a>
</div>`;
});
html += `</div>`;
}
modalContent.innerHTML = html;
} catch (error) {
modalContent.innerHTML = '<div class="error-message">Не удалось загрузить статью.</div>';
modalTitle.textContent = 'Ошибка';
}
});
function escapeHtml(str) {
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
const searchBtn = document.getElementById('searchBtn');
const resetBtn = document.getElementById('resetSearchBtn');
const searchInput = document.getElementById('searchInput');
async function performSearch() {
const query = searchInput.value.trim();
if (!query) { resetSearch(); return; }
const articlesDiv = document.getElementById('articlesList');
articlesDiv.innerHTML = '<div class="loading-spinner">Поиск...</div>';
try {
const response = await fetch(`search_knowledge.php?q=${encodeURIComponent(query)}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.length === 0) {
articlesDiv.innerHTML = '<div class="no-results">Ничего не найдено.</div>';
return;
}
let html = '';
data.forEach(item => {
html += `<div class="article-card" data-id="${item.id}">
<div class="article-title">${escapeHtml(item.title)}</div>
<div class="article-meta">Обновлено: ${item.updated_at}</div>
<div class="article-preview">${escapeHtml(item.preview)}</div>
</div>`;
});
articlesDiv.innerHTML = html;
} catch(e) {
articlesDiv.innerHTML = '<div class="error-message">Ошибка поиска.</div>';
}
}
function resetSearch() { searchInput.value = ''; location.reload(); }
searchBtn.addEventListener('click', performSearch);
resetBtn.addEventListener('click', resetSearch);
searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(); });
</script>
</body>
</html>