1098 lines
48 KiB
TypeScript
Executable File
1098 lines
48 KiB
TypeScript
Executable File
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<any>;
|
||
|
||
// Базовый 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('<img')) return html;
|
||
const base = getBackendBase();
|
||
return html.replace(
|
||
/<img([^>]*)\ssrc=["']([^"']+)["']/gi,
|
||
(_, attrs, src) => {
|
||
const fullSrc = src.startsWith('http') || src.startsWith('data:') ? src : `${base}${src.startsWith('/') ? src : `/${src}`}`;
|
||
return `<a href="${fullSrc}" target="_blank" rel="noopener noreferrer" class="kb-article-img-link"><img${attrs} src="${fullSrc}" class="kb-article-img" /></a>`;
|
||
}
|
||
);
|
||
};
|
||
|
||
// Компонент для отображения изображения с обработкой ошибок (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 (
|
||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||
{!imageError ? (
|
||
<>
|
||
{imageLoading && (
|
||
<div className="w-full h-32 bg-slate-100 flex items-center justify-center">
|
||
<div className="text-slate-400 text-xs">Загрузка...</div>
|
||
</div>
|
||
)}
|
||
<img
|
||
src={imageUrl}
|
||
alt={filename}
|
||
className={`w-full h-32 object-cover hover:opacity-90 transition-opacity ${imageLoading ? 'hidden' : ''}`}
|
||
onError={() => {
|
||
setImageError(true);
|
||
setImageLoading(false);
|
||
}}
|
||
onLoad={() => setImageLoading(false)}
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="w-full h-32 bg-slate-100 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<ImageIcon className="w-8 h-8 text-slate-400 mx-auto mb-1" />
|
||
<p className="text-xs text-slate-500 px-2 truncate max-w-full">{filename}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="p-2 text-xs text-slate-600 truncate bg-slate-50">{filename}</p>
|
||
</a>
|
||
);
|
||
};
|
||
|
||
export const KnowledgeBase: React.FC = () => {
|
||
const [categories, setCategories] = useState<KnowledgeBaseCategory[]>([]);
|
||
const [articles, setArticles] = useState<KnowledgeBaseArticle[]>([]);
|
||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedArticle, setSelectedArticle] = useState<KnowledgeBaseArticle | null>(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<Array<{ filename: string; url: string; size: number; mimetype: string }>>([]);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const quillRef = useRef<any>(null);
|
||
const [QuillEditor, setQuillEditor] = useState<ReactQuillComponent | null>(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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="space-y-6 animate-fade-in">
|
||
<div className="flex justify-between items-center">
|
||
<h3 className="text-lg font-bold text-slate-800">База знаний</h3>
|
||
<button
|
||
onClick={() => {
|
||
setFormData({
|
||
title: '',
|
||
categoryId: '',
|
||
content: '',
|
||
contentType: 'html',
|
||
tags: ''
|
||
});
|
||
setUploadedFiles([]);
|
||
setShowCreateModal(true);
|
||
}}
|
||
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Новая статья
|
||
</button>
|
||
</div>
|
||
|
||
{/* Поиск */}
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск по статьям..."
|
||
className="w-full pl-10 pr-4 py-3 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||
{/* Категории */}
|
||
<div className="lg:col-span-1">
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h4 className="font-bold text-slate-800 flex items-center gap-2">
|
||
<Folder className="w-4 h-4" />
|
||
Категории
|
||
</h4>
|
||
<button
|
||
onClick={() => setShowCreateCategoryModal(true)}
|
||
className="p-1.5 bg-primary-50 text-primary-600 rounded-lg hover:bg-primary-100 transition-colors"
|
||
title="Создать категорию"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<button
|
||
onClick={() => setSelectedCategory(null)}
|
||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||
selectedCategory === null
|
||
? 'bg-primary-50 text-primary-700 font-bold'
|
||
: 'text-slate-600 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
Все статьи
|
||
</button>
|
||
{categories.map((category) => (
|
||
<button
|
||
key={category.id}
|
||
onClick={() => setSelectedCategory(category.id)}
|
||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||
selectedCategory === category.id
|
||
? 'bg-primary-50 text-primary-700 font-bold'
|
||
: 'text-slate-600 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
{category.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список статей */}
|
||
<div className="lg:col-span-3">
|
||
{articles.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{articles.map((article) => (
|
||
<div
|
||
key={article.id}
|
||
className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 hover:shadow-md transition-shadow"
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<h5 className="font-bold text-slate-800 mb-2">{article.title}</h5>
|
||
<p className="text-sm text-slate-600 line-clamp-2 mb-3">
|
||
{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 ? '...' : ''))
|
||
}
|
||
</p>
|
||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||
{article.category && (
|
||
<span className="px-2 py-1 bg-slate-100 rounded">
|
||
{article.category.name}
|
||
</span>
|
||
)}
|
||
<span>Автор: {article.author}</span>
|
||
<span>{article.createdAt ? new Date(article.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'}</span>
|
||
<span>Просмотров: {article.viewCount}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleViewArticle(article);
|
||
}}
|
||
className="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
|
||
title="Просмотреть статью"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEditArticle(article);
|
||
}}
|
||
className="p-2 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-colors"
|
||
title="Редактировать статью"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||
<BookOpen className="w-12 h-12 mx-auto text-slate-300 mb-4" />
|
||
<p className="text-slate-500">Статьи не найдены</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create Article Modal */}
|
||
{showCreateModal && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-bold text-slate-800">Создать статью</h3>
|
||
<button
|
||
onClick={() => setShowCreateModal(false)}
|
||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Заголовок *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.title}
|
||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
placeholder="Введите заголовок статьи"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Категория</label>
|
||
<select
|
||
value={formData.categoryId}
|
||
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="">Без категории</option>
|
||
{categories.map((cat) => (
|
||
<option key={cat.id} value={cat.id.toString()}>
|
||
{cat.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Содержание *</label>
|
||
<div className="border border-slate-300 rounded-lg overflow-hidden">
|
||
{QuillEditor ? (
|
||
<QuillEditor
|
||
ref={quillRef}
|
||
theme="snow"
|
||
value={formData.content}
|
||
onChange={(value: string) => 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' }}
|
||
/>
|
||
) : (
|
||
<div className="min-h-[300px] flex items-center justify-center bg-slate-50 text-slate-500 text-sm">Загрузка редактора...</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Загрузить файлы</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
onChange={handleFileUpload}
|
||
className="hidden"
|
||
accept="image/*,.pdf,.doc,.docx,.txt,.zip,.rar"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isUploading}
|
||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
{isUploading ? 'Загрузка...' : 'Выбрать файл'}
|
||
</button>
|
||
<span className="text-xs text-slate-500">Изображения, PDF, документы, архивы (до 20MB)</span>
|
||
</div>
|
||
{uploadedFiles.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
{uploadedFiles.map((file, index) => (
|
||
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
{file.mimetype.startsWith('image/') ? (
|
||
<ImageIcon className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||
) : (
|
||
<File className="w-4 h-4 text-slate-600 flex-shrink-0" />
|
||
)}
|
||
<span className="text-sm text-slate-700 truncate">{file.filename}</span>
|
||
<span className="text-xs text-slate-500 ml-2">
|
||
({(file.size / 1024).toFixed(1)} KB)
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveFile(index)}
|
||
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Теги (через запятую)</label>
|
||
<input
|
||
type="text"
|
||
value={formData.tags}
|
||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
placeholder="например: инструкция, ремонт, настройка"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
onClick={handleCreateArticle}
|
||
disabled={!formData.title || !formData.content}
|
||
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||
>
|
||
Создать
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateModal(false)}
|
||
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* View Article Modal */}
|
||
{showViewModal && selectedArticle && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-bold text-slate-800">{selectedArticle.title}</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setShowViewModal(false);
|
||
handleEditArticle(selectedArticle);
|
||
}}
|
||
className="p-2 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-colors"
|
||
title="Редактировать"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowViewModal(false);
|
||
setSelectedArticle(null);
|
||
}}
|
||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-4 text-sm text-slate-600 pb-4 border-b border-slate-200">
|
||
{selectedArticle.category && (
|
||
<span className="px-2 py-1 bg-slate-100 rounded">
|
||
{selectedArticle.category.name}
|
||
</span>
|
||
)}
|
||
<span>Автор: {selectedArticle.author}</span>
|
||
<span>{selectedArticle.createdAt ? new Date(selectedArticle.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'}</span>
|
||
<span>Просмотров: {selectedArticle.viewCount}</span>
|
||
</div>
|
||
|
||
<div className="prose max-w-none">
|
||
{selectedArticle.contentType === 'html' ? (
|
||
<div
|
||
className="ql-editor"
|
||
dangerouslySetInnerHTML={{ __html: rewriteContentImageUrls(selectedArticle.content || '') }}
|
||
/>
|
||
) : (
|
||
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-700">{selectedArticle.content || ''}</pre>
|
||
)}
|
||
</div>
|
||
|
||
{selectedArticle.attachments && selectedArticle.attachments.length > 0 && (
|
||
<div className="pt-4 border-t border-slate-200">
|
||
<p className="text-sm font-medium text-slate-700 mb-2">Прикрепленные файлы:</p>
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||
{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 (
|
||
<div key={index} className="border border-slate-200 rounded-lg overflow-hidden bg-white">
|
||
{isImage ? (
|
||
<AttachmentImage url={url} filename={filename} />
|
||
) : (
|
||
<a
|
||
href={fileUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-2 p-3 hover:bg-slate-50 transition-colors"
|
||
>
|
||
<File className="w-5 h-5 text-slate-400" />
|
||
<span className="text-sm text-slate-700 truncate flex-1">{filename}</span>
|
||
</a>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedArticle.tags && selectedArticle.tags.length > 0 && (
|
||
<div className="pt-4 border-t border-slate-200">
|
||
<p className="text-sm font-medium text-slate-700 mb-2">Теги:</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedArticle.tags.map((tag, index) => (
|
||
<span key={index} className="px-2 py-1 bg-primary-50 text-primary-700 rounded text-xs">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Article Modal */}
|
||
{showEditModal && selectedArticle && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-bold text-slate-800">Редактировать статью</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowEditModal(false);
|
||
setSelectedArticle(null);
|
||
}}
|
||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Заголовок *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.title}
|
||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Категория</label>
|
||
<select
|
||
value={formData.categoryId}
|
||
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="">Без категории</option>
|
||
{categories.map((cat) => (
|
||
<option key={cat.id} value={cat.id.toString()}>
|
||
{cat.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Содержание *</label>
|
||
<div className="border border-slate-300 rounded-lg overflow-hidden">
|
||
{QuillEditor ? (
|
||
<QuillEditor
|
||
ref={quillRef}
|
||
theme="snow"
|
||
value={formData.content}
|
||
onChange={(value: string) => 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' }}
|
||
/>
|
||
) : (
|
||
<div className="min-h-[300px] flex items-center justify-center bg-slate-50 text-slate-500 text-sm">Загрузка редактора...</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Загрузить файлы</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
onChange={handleFileUpload}
|
||
className="hidden"
|
||
accept="image/*,.pdf,.doc,.docx,.txt,.zip,.rar"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isUploading}
|
||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
{isUploading ? 'Загрузка...' : 'Выбрать файл'}
|
||
</button>
|
||
<span className="text-xs text-slate-500">Изображения, PDF, документы, архивы (до 20MB)</span>
|
||
</div>
|
||
{uploadedFiles.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
{uploadedFiles.map((file, index) => (
|
||
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
{file.mimetype.startsWith('image/') ? (
|
||
<ImageIcon className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||
) : (
|
||
<File className="w-4 h-4 text-slate-600 flex-shrink-0" />
|
||
)}
|
||
<span className="text-sm text-slate-700 truncate">{file.filename}</span>
|
||
<span className="text-xs text-slate-500 ml-2">
|
||
({(file.size / 1024).toFixed(1)} KB)
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveFile(index)}
|
||
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Теги (через запятую)</label>
|
||
<input
|
||
type="text"
|
||
value={formData.tags}
|
||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
placeholder="например: инструкция, ремонт, настройка"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
onClick={handleUpdateArticle}
|
||
disabled={!formData.title || !formData.content}
|
||
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowEditModal(false);
|
||
setSelectedArticle(null);
|
||
}}
|
||
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Category Modal */}
|
||
{showCreateCategoryModal && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-bold text-slate-800">Создать категорию</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateCategoryModal(false);
|
||
setNewCategoryName('');
|
||
}}
|
||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Название категории *</label>
|
||
<input
|
||
type="text"
|
||
value={newCategoryName}
|
||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
placeholder="Введите название категории"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
onClick={handleCreateCategory}
|
||
disabled={!newCategoryName || !newCategoryName.trim()}
|
||
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||
>
|
||
Создать
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateCategoryModal(false);
|
||
setNewCategoryName('');
|
||
}}
|
||
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|