import React, { useState, useEffect, useRef } from 'react'; import { authFetch } from '../../services/apiClient'; import { BookOpen, Search, Folder, FileText, Plus, Edit, Eye, CheckCircle2, X, Upload, Image as ImageIcon, File, Trash2 } from 'lucide-react'; import { KnowledgeBaseArticle, KnowledgeBaseCategory } from '../../types'; import { CURRENT_USER_MOCK } from '../../constants'; import './KnowledgeBase.css'; // ReactQuill loaded dynamically when KnowledgeBase mounts to reduce initial bundle type ReactQuillComponent = React.ComponentType; // Базовый URL бэкенда для загрузки файлов (как в PR фото отчётах) const getBackendBase = () => { const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api'; return apiBase.replace(/\/api\/?$/, ''); }; // Подставить в HTML контента img src базовый URL бэкенда и обернуть в ссылку (клик — полный размер) const rewriteContentImageUrls = (html: string): string => { if (!html || !html.includes(']*)\ssrc=["']([^"']+)["']/gi, (_, attrs, src) => { const fullSrc = src.startsWith('http') || src.startsWith('data:') ? src : `${base}${src.startsWith('/') ? src : `/${src}`}`; return ``; } ); }; // Компонент для отображения изображения с обработкой ошибок (URL через бэкенд, как в PR фото) const AttachmentImage: React.FC<{ url: string; filename: string }> = ({ url, filename }) => { const [imageError, setImageError] = useState(false); const [imageLoading, setImageLoading] = useState(true); const base = getBackendBase(); const imageUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`; const fileUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`; return ( {!imageError ? ( <> {imageLoading && (
Загрузка...
)} {filename} { setImageError(true); setImageLoading(false); }} onLoad={() => setImageLoading(false)} /> ) : (

{filename}

)}

{filename}

); }; export const KnowledgeBase: React.FC = () => { const [categories, setCategories] = useState([]); const [articles, setArticles] = useState([]); const [selectedCategory, setSelectedCategory] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedArticle, setSelectedArticle] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showViewModal, setShowViewModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false); const [newCategoryName, setNewCategoryName] = useState(''); const [formData, setFormData] = useState({ title: '', categoryId: '', content: '', contentType: 'html' as 'markdown' | 'html', tags: '' }); const [uploadedFiles, setUploadedFiles] = useState>([]); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); const quillRef = useRef(null); const [QuillEditor, setQuillEditor] = useState(null); useEffect(() => { let cancelled = false; Promise.all([ import('react-quill-new'), import('react-quill-new/dist/quill.snow.css') ]).then(([mod]) => { if (!cancelled) setQuillEditor(() => mod.default); }).catch(err => console.error('Failed to load Quill editor', err)); return () => { cancelled = true; }; }, []); // Функция для очистки HTML и получения текстового превью const stripHtml = (html: string): string => { const tmp = document.createElement('DIV'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; }; useEffect(() => { fetchCategories(); fetchArticles(); }, [selectedCategory, searchQuery]); const fetchCategories = async () => { try { const response = await authFetch('/api/office/knowledge-base/categories'); if (response.ok) { const data = await response.json(); setCategories(data); } } catch (error) { console.error('Ошибка загрузки категорий:', error); } }; const fetchArticles = async () => { try { const params = new URLSearchParams(); if (selectedCategory) params.append('categoryId', selectedCategory.toString()); if (searchQuery) params.append('search', searchQuery); params.append('isPublished', 'true'); const response = await authFetch(`/api/office/knowledge-base/articles?${params}`); if (response.ok) { const data = await response.json(); // Нормализуем данные из API const normalizedData = data.map((article: any) => { // Обрабатываем attachments - нормализуем URL let attachments = Array.isArray(article.attachments) ? article.attachments : (typeof article.attachments === 'string' ? JSON.parse(article.attachments || '[]') : []); // Убеждаемся, что все URL начинаются с /uploads/ если они относительные attachments = attachments.map((url: string) => { if (!url) return url; // Если URL уже абсолютный (http/https), оставляем как есть if (url.startsWith('http://') || url.startsWith('https://')) { return url; } // Если URL начинается с /, оставляем как есть if (url.startsWith('/')) { return url; } // Иначе добавляем / в начало return `/${url}`; }); return { id: article.id, title: article.title || '', slug: article.slug || '', categoryId: article.category_id || article.categoryId, category: article.category_name ? { id: article.category_id, name: article.category_name } : article.category, content: article.content || '', contentType: article.content_type || article.contentType || 'markdown', author: article.author || '', version: article.version || 1, parentVersionId: article.parent_version_id || article.parentVersionId, isPublished: article.is_published !== undefined ? article.is_published : (article.isPublished !== undefined ? article.isPublished : true), tags: Array.isArray(article.tags) ? article.tags : (typeof article.tags === 'string' ? JSON.parse(article.tags || '[]') : []), attachments: attachments, viewCount: article.view_count || article.viewCount || 0, createdAt: article.created_at || article.createdAt, updatedAt: article.updated_at || article.updatedAt, publishedAt: article.published_at || article.publishedAt }; }); setArticles(normalizedData); } } catch (error) { console.error('Ошибка загрузки статей:', error); } }; const handleCreateCategory = async () => { try { if (!newCategoryName || !newCategoryName.trim()) { alert('Пожалуйста, укажите название категории'); return; } const response = await authFetch('/api/office/knowledge-base/categories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newCategoryName.trim() }) }); if (response.ok) { const newCategory = await response.json(); // Обновляем список категорий await fetchCategories(); setShowCreateCategoryModal(false); setNewCategoryName(''); alert('Категория успешно создана'); } else { const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' })); alert(`Ошибка создания категории: ${errorData.error || 'Неизвестная ошибка'}`); } } catch (error: any) { console.error('Ошибка создания категории:', error); alert(`Ошибка создания категории: ${error.message || 'Неизвестная ошибка'}`); } }; const handleViewArticle = (article: KnowledgeBaseArticle) => { setSelectedArticle(article); setShowViewModal(true); }; const handleEditArticle = (article: KnowledgeBaseArticle) => { setSelectedArticle(article); setFormData({ title: article.title || '', categoryId: article.categoryId ? article.categoryId.toString() : '', content: article.content || '', contentType: article.contentType || 'html', tags: Array.isArray(article.tags) ? article.tags.join(', ') : '' }); // Восстанавливаем загруженные файлы из attachments if (article.attachments && Array.isArray(article.attachments) && article.attachments.length > 0) { const files = article.attachments.map((url: string) => ({ filename: url.split('/').pop() || 'file', url: url, size: 0, mimetype: url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? 'image/jpeg' : 'application/octet-stream' })); setUploadedFiles(files); } else { setUploadedFiles([]); } setShowEditModal(true); }; const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setIsUploading(true); try { const formData = new FormData(); formData.append('file', file); const response = await authFetch('/api/office/knowledge-base/upload', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); setUploadedFiles([...uploadedFiles, result.file]); // Если это изображение, вставляем его в редактор if (file.type.startsWith('image/')) { const quill = quillRef.current?.getEditor(); if (quill) { const range = quill.getSelection(); const index = range ? range.index : quill.getLength(); quill.insertEmbed(index, 'image', result.file.url); quill.setSelection(index + 1); } } } else { const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' })); alert(`Ошибка загрузки файла: ${errorData.error || 'Неизвестная ошибка'}`); } } catch (error: any) { console.error('Ошибка загрузки файла:', error); alert(`Ошибка загрузки файла: ${error.message || 'Неизвестная ошибка'}`); } finally { setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; const handleRemoveFile = (index: number) => { setUploadedFiles(uploadedFiles.filter((_, i) => i !== index)); }; const handleImageUpload = () => { const input = document.createElement('input'); input.setAttribute('type', 'file'); input.setAttribute('accept', 'image/*'); input.onchange = async () => { const file = input.files?.[0]; if (!file) return; setIsUploading(true); try { const formData = new FormData(); formData.append('file', file); const response = await authFetch('/api/office/knowledge-base/upload', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); const quill = quillRef.current?.getEditor(); if (quill) { const range = quill.getSelection(); const index = range ? range.index : quill.getLength(); quill.insertEmbed(index, 'image', result.file.url); quill.setSelection(index + 1); } setUploadedFiles([...uploadedFiles, result.file]); } else { const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' })); alert(`Ошибка загрузки изображения: ${errorData.error || 'Неизвестная ошибка'}`); } } catch (error: any) { console.error('Ошибка загрузки изображения:', error); alert(`Ошибка загрузки изображения: ${error.message || 'Неизвестная ошибка'}`); } finally { setIsUploading(false); } }; input.click(); }; const handleUpdateArticle = async () => { if (!selectedArticle) return; try { if (!formData.title || !formData.title.trim()) { alert('Пожалуйста, укажите заголовок статьи'); return; } if (!formData.content || !formData.content.trim()) { alert('Пожалуйста, укажите содержание статьи'); return; } const tagsArray = formData.tags ? formData.tags.split(',').map(t => t.trim()).filter(Boolean) : []; const attachmentsArray = uploadedFiles.map(f => f.url); const response = await authFetch(`/api/office/knowledge-base/articles/${selectedArticle.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: formData.title, categoryId: formData.categoryId ? parseInt(formData.categoryId) : null, content: formData.content, contentType: formData.contentType, tags: tagsArray, attachments: attachmentsArray }) }); if (response.ok) { fetchArticles(); setShowEditModal(false); setSelectedArticle(null); setUploadedFiles([]); alert('Статья успешно обновлена'); } else { const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' })); alert(`Ошибка обновления статьи: ${errorData.error || 'Неизвестная ошибка'}`); } } catch (error: any) { console.error('Ошибка обновления статьи:', error); alert(`Ошибка обновления статьи: ${error.message || 'Неизвестная ошибка'}`); } }; const handleCreateArticle = async () => { try { if (!formData.title || !formData.title.trim()) { alert('Пожалуйста, укажите заголовок статьи'); return; } if (!formData.content || !formData.content.trim()) { alert('Пожалуйста, укажите содержание статьи'); return; } const tagsArray = formData.tags ? formData.tags.split(',').map(t => t.trim()).filter(Boolean) : []; const attachmentsArray = uploadedFiles.map(f => f.url); const response = await authFetch('/api/office/knowledge-base/articles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: formData.title, slug: formData.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), categoryId: formData.categoryId ? parseInt(formData.categoryId) : null, content: formData.content, contentType: formData.contentType, author: CURRENT_USER_MOCK.name, tags: tagsArray, attachments: attachmentsArray, isPublished: true }) }); if (response.ok) { const newArticle = await response.json(); // Нормализуем данные const normalizedArticle = { id: newArticle.id, title: newArticle.title || formData.title, slug: newArticle.slug || '', categoryId: newArticle.category_id || (formData.categoryId ? parseInt(formData.categoryId) : null), category: categories.find(c => c.id === (formData.categoryId ? parseInt(formData.categoryId) : null)), content: newArticle.content || formData.content, contentType: newArticle.content_type || formData.contentType, author: newArticle.author || CURRENT_USER_MOCK.name, version: newArticle.version || 1, isPublished: newArticle.is_published !== undefined ? newArticle.is_published : true, tags: tagsArray, attachments: [], viewCount: 0, createdAt: newArticle.created_at, updatedAt: newArticle.updated_at, publishedAt: newArticle.published_at }; setArticles([normalizedArticle, ...articles]); setShowCreateModal(false); setFormData({ title: '', categoryId: '', content: '', contentType: 'html', tags: '' }); setUploadedFiles([]); } else { const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' })); alert(`Ошибка создания статьи: ${errorData.error || 'Неизвестная ошибка'}`); } } catch (error: any) { console.error('Ошибка создания статьи:', error); alert(`Ошибка создания статьи: ${error.message || 'Неизвестная ошибка'}`); } }; return (

База знаний

{/* Поиск */}
setSearchQuery(e.target.value)} placeholder="Поиск по статьям..." className="w-full pl-10 pr-4 py-3 border border-slate-300 rounded-lg text-sm" />
{/* Категории */}

Категории

{categories.map((category) => ( ))}
{/* Список статей */}
{articles.length > 0 ? (
{articles.map((article) => (
{article.title}

{article.contentType === 'html' ? (() => { const textContent = stripHtml(article.content || ''); return textContent.substring(0, 150) + (textContent.length > 150 ? '...' : ''); })() : ((article.content || '').substring(0, 150) + (article.content && article.content.length > 150 ? '...' : '')) }

{article.category && ( {article.category.name} )} Автор: {article.author} {article.createdAt ? new Date(article.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'} Просмотров: {article.viewCount}
))}
) : (

Статьи не найдены

)}
{/* Create Article Modal */} {showCreateModal && (

Создать статью

setFormData({ ...formData, title: e.target.value })} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" placeholder="Введите заголовок статьи" required />
{QuillEditor ? ( setFormData({ ...formData, content: value })} modules={{ toolbar: { container: [ [{ 'header': [1, 2, 3, 4, 5, 6, false] }], ['bold', 'italic', 'underline', 'strike'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], [{ 'script': 'sub'}, { 'script': 'super' }], [{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'color': [] }, { 'background': [] }], [{ 'align': [] }], ['link', 'image'], ['clean'] ], handlers: { image: handleImageUpload } } }} formats={[ 'header', 'bold', 'italic', 'underline', 'strike', 'list', 'bullet', 'script', 'indent', 'color', 'background', 'align', 'link', 'image' ]} placeholder="Введите содержание статьи..." style={{ minHeight: '300px' }} /> ) : (
Загрузка редактора...
)}
Изображения, PDF, документы, архивы (до 20MB)
{uploadedFiles.length > 0 && (
{uploadedFiles.map((file, index) => (
{file.mimetype.startsWith('image/') ? ( ) : ( )} {file.filename} ({(file.size / 1024).toFixed(1)} KB)
))}
)}
setFormData({ ...formData, tags: e.target.value })} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" placeholder="например: инструкция, ремонт, настройка" />
)} {/* View Article Modal */} {showViewModal && selectedArticle && (

{selectedArticle.title}

{selectedArticle.category && ( {selectedArticle.category.name} )} Автор: {selectedArticle.author} {selectedArticle.createdAt ? new Date(selectedArticle.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'} Просмотров: {selectedArticle.viewCount}
{selectedArticle.contentType === 'html' ? (
) : (
{selectedArticle.content || ''}
)}
{selectedArticle.attachments && selectedArticle.attachments.length > 0 && (

Прикрепленные файлы:

{selectedArticle.attachments.map((url: string, index: number) => { const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(url); const filename = url.split('/').pop() || `Файл ${index + 1}`; const base = getBackendBase(); const fileUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`; return (
{isImage ? ( ) : ( {filename} )}
); })}
)} {selectedArticle.tags && selectedArticle.tags.length > 0 && (

Теги:

{selectedArticle.tags.map((tag, index) => ( {tag} ))}
)}
)} {/* Edit Article Modal */} {showEditModal && selectedArticle && (

Редактировать статью

setFormData({ ...formData, title: e.target.value })} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" required />
{QuillEditor ? ( setFormData({ ...formData, content: value })} modules={{ toolbar: { container: [ [{ 'header': [1, 2, 3, 4, 5, 6, false] }], ['bold', 'italic', 'underline', 'strike'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], [{ 'script': 'sub'}, { 'script': 'super' }], [{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'color': [] }, { 'background': [] }], [{ 'align': [] }], ['link', 'image'], ['clean'] ], handlers: { image: handleImageUpload } } }} formats={[ 'header', 'bold', 'italic', 'underline', 'strike', 'list', 'bullet', 'script', 'indent', 'color', 'background', 'align', 'link', 'image' ]} placeholder="Введите содержание статьи..." style={{ minHeight: '300px' }} /> ) : (
Загрузка редактора...
)}
Изображения, PDF, документы, архивы (до 20MB)
{uploadedFiles.length > 0 && (
{uploadedFiles.map((file, index) => (
{file.mimetype.startsWith('image/') ? ( ) : ( )} {file.filename} ({(file.size / 1024).toFixed(1)} KB)
))}
)}
setFormData({ ...formData, tags: e.target.value })} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" placeholder="например: инструкция, ремонт, настройка" />
)} {/* Create Category Modal */} {showCreateCategoryModal && (

Создать категорию

setNewCategoryName(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" placeholder="Введите название категории" required />
)}
); };