ваше сообщение коммита

This commit is contained in:
2025-12-10 09:27:36 +03:00
parent 13ac707844
commit 863759e3e7
21 changed files with 1738 additions and 301 deletions

View File

@@ -9,9 +9,9 @@ http {
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0;
# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
# limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
# limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# HTTP сервер для локальной разработки (БЕЗ SSL)
server {
@@ -30,8 +30,8 @@ http {
# Основной location
location / {
# Rate limiting для основных страниц
limit_req zone=req_limit_per_ip burst=20 nodelay;
# Rate limiting для основных страниц (отключено)
# limit_req zone=req_limit_per_ip burst=20 nodelay;
try_files $uri $uri/ /index.html;
@@ -59,8 +59,8 @@ http {
# API
location /api/ {
# Rate limiting для API (более строгое)
limit_req zone=api_limit_per_ip burst=100 nodelay;
# Rate limiting для API (отключено)
# limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
proxy_connect_timeout 120s;

View File

@@ -9,9 +9,9 @@ http {
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0;
# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s;
# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
# limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
# limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s;
# Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot {
@@ -108,8 +108,8 @@ http {
# Основной location
location / {
# Rate limiting для основных страниц
limit_req zone=req_limit_per_ip burst=20 nodelay;
# Rate limiting для основных страниц (отключено)
# limit_req zone=req_limit_per_ip burst=20 nodelay;
try_files $uri $uri/ /index.html;
@@ -134,8 +134,8 @@ http {
# API
location /api/ {
# Rate limiting для API (более строгое)
limit_req zone=api_limit_per_ip burst=10 nodelay;
# Rate limiting для API (отключено)
# limit_req zone=api_limit_per_ip burst=10 nodelay;
proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
proxy_connect_timeout 120s;

View File

@@ -157,7 +157,7 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -438,10 +438,58 @@ watch(() => props.pageId, (newId, oldId) => {
}
}, { immediate: true });
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.content-text video, .page-article video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
onMounted(() => {
if (props.pageId) {
loadPage();
}
setupVideoErrorHandlers();
});
</script>

View File

@@ -24,19 +24,19 @@ import api from '../../api/axios';
// Функция для загрузки и регистрации модуля изменения размера изображений
async function loadImageResizeModule() {
try {
try {
// Используем динамический импорт для совместимости с Vite
const module = await import('quill-image-resize-module');
const ImageResize = module.default || module.ImageResize || module;
if (ImageResize && typeof ImageResize === 'function') {
Quill.register('modules/imageResize', ImageResize);
Quill.register('modules/imageResize', ImageResize);
return true;
} else if (ImageResize && ImageResize.default && typeof ImageResize.default === 'function') {
Quill.register('modules/imageResize', ImageResize.default);
return true;
}
} catch (error) {
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
} catch (error) {
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
}
return false;
}
@@ -81,22 +81,22 @@ onMounted(async () => {
// Конфигурация модулей
const modulesConfig = {
toolbar: {
container: toolbarOptions,
handlers: {
'image': handleImageClick,
'video': handleVideoClick
}
toolbar: {
container: toolbarOptions,
handlers: {
'image': handleImageClick,
'video': handleVideoClick
}
}
};
// Добавляем imageResize только если модуль загружен
if (imageResizeLoaded) {
modulesConfig.imageResize = {
parchment: Quill.import('parchment'),
modules: ['Resize', 'DisplaySize', 'Toolbar']
parchment: Quill.import('parchment'),
modules: ['Resize', 'DisplaySize', 'Toolbar']
};
}
}
// Инициализация Quill
quill = new Quill(editorContainer.value, {
@@ -108,12 +108,21 @@ onMounted(async () => {
// Устанавливаем начальное значение
if (props.modelValue) {
quill.root.innerHTML = props.modelValue;
// Обрабатываем существующие видео и настраиваем обработчики
setTimeout(() => {
wrapExistingVideos();
setupVideoLoadingHandlers();
}, 100);
}
// Слушаем изменения
quill.on('text-change', () => {
const html = quill.root.innerHTML;
emit('update:modelValue', html);
// Настраиваем обработчики для новых видео после изменений
setTimeout(() => {
setupVideoLoadingHandlers();
}, 100);
});
});
@@ -121,9 +130,46 @@ onMounted(async () => {
watch(() => props.modelValue, (newValue) => {
if (quill && quill.root.innerHTML !== newValue) {
quill.root.innerHTML = newValue || '';
// Обрабатываем существующие видео в контенте
setTimeout(() => {
wrapExistingVideos();
setupVideoLoadingHandlers();
}, 100);
}
});
// Обертка существующих видео в контейнер с индикатором загрузки
function wrapExistingVideos() {
if (!quill || !quill.root) return;
const videoElements = quill.root.querySelectorAll('video.ql-video:not(.video-wrapper video)');
videoElements.forEach((video) => {
// Проверяем, не обернут ли уже видео
if (video.closest('.video-wrapper')) return;
// Проверяем, является ли это локальный файл
const src = video.getAttribute('src');
if (src && src.includes('/api/uploads/media/') && src.includes('/file')) {
// Создаем обертку
const wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
// Создаем индикатор загрузки
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'video-loading-indicator';
loadingIndicator.style.display = 'flex';
loadingIndicator.innerHTML = '<div class="spinner"></div><span>Загрузка видео...</span>';
// Вставляем обертку перед видео
video.parentNode?.insertBefore(wrapper, video);
// Перемещаем видео и индикатор в обертку
wrapper.appendChild(loadingIndicator);
wrapper.appendChild(video);
}
});
}
// Обработка вставки изображения
function handleImageClick() {
const input = document.createElement('input');
@@ -188,7 +234,48 @@ function handleImageClick() {
response: error.response?.data,
status: error.response?.status
});
alert('Ошибка загрузки изображения: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
// Обработка ошибок аналогично обработке ошибок видео
let errorMessage = 'Неизвестная ошибка';
try {
// Проверяем статус ошибки для специальных случаев
if (error.response?.status === 503) {
errorMessage = error.response?.data?.message ||
'Сервер временно недоступен. Пожалуйста, попробуйте позже.';
}
// Приоритет 1: response.data.message (если success: false)
else if (error.response?.data?.success === false && error.response?.data?.message) {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
// Приоритет 2: response.data.error.message (формат errorHandler)
else if (error.response?.data?.error?.message) {
errorMessage = String(error.response.data.error.message);
}
// Приоритет 3: response.data.message
else if (error.response?.data?.message) {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
// Приоритет 4: response.data.error
else if (error.response?.data?.error) {
errorMessage = typeof error.response.data.error === 'string'
? error.response.data.error
: 'Ошибка сервера';
}
// Приоритет 5: error.message
else if (error.message) {
errorMessage = error.message;
}
} catch (e) {
console.error('[RichTextEditor] Ошибка при обработке ошибки загрузки изображения:', e);
errorMessage = error?.message || 'Ошибка при загрузке изображения';
}
alert('Ошибка загрузки изображения: ' + errorMessage);
}
};
}
@@ -244,9 +331,18 @@ function handleVideoClick() {
const isLocalFile = fullUrl.includes('/api/uploads/media/') && fullUrl.includes('/file');
if (isLocalFile) {
// Для локальных файлов вставляем тег <video> напрямую (best practice)
const videoHtml = `<video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${fullUrl}"></video>`;
// Для локальных файлов вставляем тег <video> с оберткой для индикатора загрузки
const videoHtml = `<div class="video-wrapper"><div class="video-loading-indicator" style="display: flex;"><div class="spinner"></div><span>Загрузка видео...</span></div><video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${fullUrl}"></video></div>`;
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
setupVideoLoadingHandlers();
setTimeout(() => {
setupVideoLoadingHandlers();
}, 50);
setTimeout(() => {
setupVideoLoadingHandlers();
}, 200);
} else {
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill
quill.insertEmbed(range.index, 'video', fullUrl);
@@ -269,9 +365,113 @@ function handleVideoClick() {
console.error('[RichTextEditor] Детали ошибки:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
status: error.response?.status,
error: error
});
alert('Ошибка загрузки видео: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
// Правильная обработка ошибок с учетом всех возможных форматов
let errorMessage = 'Неизвестная ошибка';
try {
// Функция для безопасного извлечения сообщения из объекта
const extractMessage = (obj) => {
if (!obj) return null;
if (typeof obj === 'string') return obj;
if (typeof obj === 'object') {
// Проверяем различные возможные поля с сообщением
if (obj.message) return String(obj.message);
if (obj.error && typeof obj.error === 'string') return obj.error;
if (obj.error && typeof obj.error === 'object' && obj.error.message) return String(obj.error.message);
if (obj.detail) return String(obj.detail);
if (obj.msg) return String(obj.msg);
// Если ничего не найдено, возвращаем null
return null;
}
return String(obj);
};
// Проверяем статус ошибки для специальных случаев
if (error.response?.status === 503) {
// Service Unavailable - обычно ошибка подключения к БД
errorMessage = error.response?.data?.message ||
'Сервер временно недоступен. Пожалуйста, попробуйте позже.';
}
// Приоритет 1: response.data.message (если success: false)
else if (error.response?.data?.success === false && error.response?.data?.message) {
const msg = extractMessage(error.response.data.message);
if (msg) {
errorMessage = msg;
} else {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
}
// Приоритет 2: response.data.error.message (формат errorHandler)
else if (error.response?.data?.error?.message) {
errorMessage = String(error.response.data.error.message);
}
// Приоритет 3: response.data.message
else if (error.response?.data?.message) {
const msg = extractMessage(error.response.data.message);
if (msg) {
errorMessage = msg;
} else {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера (неверный формат сообщения)';
}
}
// Приоритет 4: response.data.error (может быть объектом или строкой)
else if (error.response?.data?.error) {
const err = error.response.data.error;
if (typeof err === 'string') {
errorMessage = err;
} else if (err && typeof err === 'object') {
// Пытаемся извлечь понятное сообщение
errorMessage = err.message || err.detail || err.msg || 'Ошибка сервера';
} else {
errorMessage = String(err);
}
}
// Приоритет 5: response.data (может быть объектом с ошибкой)
else if (error.response?.data) {
const data = error.response.data;
const msg = extractMessage(data);
if (msg) {
errorMessage = msg;
} else if (typeof data === 'string') {
errorMessage = data;
} else if (data && typeof data === 'object') {
errorMessage = data.message || 'Ошибка сервера';
}
}
// Приоритет 6: error.message
else if (error.message) {
errorMessage = error.message;
}
// Приоритет 7: error сам по себе
else {
errorMessage = String(error);
}
} catch (e) {
// Если что-то пошло не так при обработке ошибки, используем базовое сообщение
console.error('[RichTextEditor] Ошибка при обработке ошибки:', e);
errorMessage = error?.message || 'Ошибка при загрузке видео';
}
// Гарантируем, что errorMessage всегда строка
if (typeof errorMessage !== 'string') {
console.error('[RichTextEditor] errorMessage не является строкой:', errorMessage, typeof errorMessage);
try {
errorMessage = JSON.stringify(errorMessage);
} catch (e) {
errorMessage = String(errorMessage);
}
}
console.error('[RichTextEditor] Финальное сообщение об ошибке:', errorMessage);
alert('Ошибка загрузки видео: ' + errorMessage);
}
};
} else {
@@ -288,9 +488,18 @@ function handleVideoClick() {
const isLocalFile = url.includes('/api/uploads/media/') && url.includes('/file');
if (isLocalFile) {
// Для локальных файлов вставляем тег <video> напрямую (best practice)
const videoHtml = `<video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${url}"></video>`;
// Для локальных файлов вставляем тег <video> с оберткой для индикатора загрузки
const videoHtml = `<div class="video-wrapper"><div class="video-loading-indicator" style="display: flex;"><div class="spinner"></div><span>Загрузка видео...</span></div><video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${url}"></video></div>`;
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
setupVideoLoadingHandlers();
setTimeout(() => {
setupVideoLoadingHandlers();
}, 50);
setTimeout(() => {
setupVideoLoadingHandlers();
}, 200);
} else {
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill
quill.insertEmbed(range.index, 'video', url);
@@ -305,6 +514,87 @@ function handleVideoClick() {
}
}
// Настройка обработчиков событий для индикатора загрузки видео
function setupVideoLoadingHandlers() {
if (!quill || !quill.root) return;
const videoElements = quill.root.querySelectorAll('.video-wrapper video');
videoElements.forEach((video) => {
const wrapper = video.closest('.video-wrapper');
if (!wrapper) return;
const loadingIndicator = wrapper.querySelector('.video-loading-indicator');
if (!loadingIndicator) return;
// Показываем индикатор сразу (он должен быть виден по умолчанию)
loadingIndicator.style.display = 'flex';
// Проверяем, не загружено ли уже видео
if (video.readyState >= 2) {
// Видео уже загружено, скрываем индикатор
loadingIndicator.style.display = 'none';
}
// Показываем индикатор при начале загрузки
const handleLoadStart = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'flex';
}
};
// Скрываем индикатор, когда видео готово к воспроизведению
const handleCanPlay = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
};
// Показываем индикатор, если видео буферизуется
const handleWaiting = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'flex';
}
};
// Скрываем индикатор при воспроизведении
const handlePlaying = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
};
// Обработка ошибок
const handleError = (e) => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
console.error('[RichTextEditor] Ошибка загрузки видео в плеере:', e);
};
// Добавляем обработчики событий (удаляем старые, если есть)
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('playing', handlePlaying);
video.removeEventListener('error', handleError);
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('playing', handlePlaying);
video.addEventListener('error', handleError);
});
}
// Настраиваем обработчики при изменении контента
watch(() => props.modelValue, () => {
if (quill) {
setTimeout(() => {
setupVideoLoadingHandlers();
}, 100);
}
});
onBeforeUnmount(() => {
if (quill) {
quill = null;
@@ -455,5 +745,66 @@ defineExpose({
cursor: nwse-resize;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
/* Стили для обертки видео с индикатором загрузки */
:deep(.video-wrapper) {
position: relative;
max-width: 100%;
width: 100%;
margin: 1.5rem 0;
border-radius: 8px;
overflow: hidden;
background: #000;
min-height: 400px;
}
:deep(.video-wrapper video) {
max-width: 100%;
width: 100%;
height: auto;
min-height: 400px;
border-radius: 8px;
display: block;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #000;
}
/* Индикатор загрузки видео */
:deep(.video-loading-indicator) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
z-index: 10;
border-radius: 8px;
}
:deep(.video-loading-indicator .spinner) {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
:deep(.video-loading-indicator span) {
font-size: 1rem;
font-weight: 500;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -27,10 +27,11 @@ export default {
const res = await api.get(`/pages/${id}`);
return res.data;
},
async updatePage(id, data) {
console.log('[pagesService] updatePage:', { id, data });
async updatePage(id, data, isFormData = false) {
console.log('[pagesService] updatePage:', { id, isFormData });
try {
const res = await api.patch(`/pages/${id}`, data);
const config = isFormData ? { headers: { 'Content-Type': 'multipart/form-data' } } : undefined;
const res = await api.patch(`/pages/${id}`, data, config);
console.log('[pagesService] updatePage успешно:', res.data);
return res.data;
} catch (error) {

View File

@@ -33,9 +33,9 @@ const normalizeDomainToAscii = (domain) => {
// Функция для генерации nginx конфигурации
function getNginxConfig(domain, serverPort) {
return `# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
return `# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
# limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
# limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot {
@@ -82,8 +82,8 @@ server {
# Основной location для фронтенда
location / {
# Rate limiting для основных страниц
limit_req zone=req_limit_per_ip burst=20 nodelay;
# Rate limiting для основных страниц (отключено)
# limit_req zone=req_limit_per_ip burst=20 nodelay;
proxy_pass http://localhost:${serverPort};
proxy_set_header Host $host;
@@ -102,8 +102,8 @@ server {
# API проксирование к backend через туннель
location /api/ {
# Rate limiting для API (более строгое)
limit_req zone=api_limit_per_ip burst=100 nodelay;
# Rate limiting для API (отключено)
# limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://localhost:8000/api/;
proxy_set_header Host $host;
@@ -472,9 +472,9 @@ app.post('/tunnel/create', async (req, res) => {
await ssh.execCommand(\`\${installPackages} \${mailPackages}\`);
// Создание конфигурации NGINX с полной защитой
const nginxConfig = \`# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
const nginxConfig = \`# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
# limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
# limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot {
@@ -521,8 +521,8 @@ server {
# Основной location для фронтенда
location / {
# Rate limiting для основных страниц
limit_req zone=req_limit_per_ip burst=20 nodelay;
# Rate limiting для основных страниц (отключено)
# limit_req zone=req_limit_per_ip burst=20 nodelay;
proxy_pass http://localhost:\${serverPort};
proxy_set_header Host $host;
@@ -541,8 +541,8 @@ server {
# API проксирование к backend через туннель
location /api/ {
# Rate limiting для API (более строгое)
limit_req zone=api_limit_per_ip burst=100 nodelay;
# Rate limiting для API (отключено)
# limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://localhost:8000/api/;
proxy_set_header Host $host;

View File

@@ -26,6 +26,15 @@
<p>{{ isEditMode ? 'Редактируйте существующую страницу' : 'Создайте новую страницу для вашего DLE' }}</p>
</div>
<div class="header-actions">
<button
v-if="isEditMode && canManageLegalDocs && address"
class="btn btn-danger"
@click="deletePage"
type="button"
>
<i class="fas fa-trash"></i>
Удалить
</button>
<button class="close-btn" @click="goBack">×</button>
</div>
</div>
@@ -279,6 +288,24 @@ function goBack() {
router.push({ name: 'content-list' });
}
async function deletePage() {
if (!isEditMode.value || !editId.value) {
return;
}
if (!confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить. Все связанные файлы также будут удалены.')) {
return;
}
try {
await pagesService.deletePage(editId.value);
router.push({ name: 'content-list' });
} catch (error) {
console.error('Ошибка удаления страницы:', error);
alert('Ошибка при удалении страницы: ' + (error?.response?.data?.error || error?.message || 'Неизвестная ошибка'));
}
}
function onFileChange(e) {
const f = e.target.files && e.target.files[0];
if (f) {
@@ -432,8 +459,14 @@ async function handleSubmit() {
fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility);
// Всегда отправляем required_permission:
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
// - Если visibility = internal, отправляем значение или пустую строку
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim());
} else {
// Явно устанавливаем пустое значение для public страниц
fd.append('required_permission', '');
}
fd.append('format', form.value.format);
if (fileBlob.value) {
@@ -470,8 +503,14 @@ async function handleSubmit() {
fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility);
// Всегда отправляем required_permission:
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
// - Если visibility = internal, отправляем значение или пустую строку
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim());
} else {
// Явно устанавливаем пустое значение для public страниц
fd.append('required_permission', '');
}
fd.append('format', form.value.format);
fd.append('file', fileBlob.value);

View File

@@ -112,11 +112,12 @@ const filtered = computed(() => {
}
// Фильтр по правам доступа
// Если у документа нет required_permission, он доступен всем аутентифицированным пользователям
if (!p.required_permission) {
return false; // Документ без прав не показываем
return true; // Документ без прав доступен всем
}
// Проверяем права пользователя
// Проверяем права пользователя для документов с указанными правами
if (p.required_permission === PERMISSIONS.VIEW_BASIC_DOCS) {
return hasPermission(PERMISSIONS.VIEW_BASIC_DOCS);
}
@@ -129,7 +130,8 @@ const filtered = computed(() => {
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
}
return false;
// Если required_permission указан, но не распознан, показываем документ
return true;
});
});

View File

@@ -39,10 +39,14 @@
</div>
</div>
<div class="header-actions">
<button v-if="canEditData && address" class="btn btn-outline" @click="goToEdit">
<button v-if="canEditPage" class="btn btn-outline" @click="goToEdit">
<i class="fas fa-edit"></i>
Редактировать
</button>
<button v-if="canManageLegalDocs && address" class="btn btn-danger" @click="deletePage" type="button">
<i class="fas fa-trash"></i>
Удалить
</button>
<button class="close-btn" @click="goBack">×</button>
</div>
</div>
@@ -147,13 +151,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, onMounted, watch, nextTick, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
import api from '../../api/axios';
import { useAuthContext } from '../../composables/useAuth';
import { usePermissions } from '../../composables/usePermissions';
import { PERMISSIONS } from '../../composables/permissions';
// Props
const props = defineProps({
@@ -184,9 +189,31 @@ const router = useRouter();
// Состояние
const page = ref(null);
const { address } = useAuthContext();
const { canEditData } = usePermissions();
const { canEditData, hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => {
try {
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
} catch (e) {
console.error('[PageView] Ошибка проверки прав MANAGE_LEGAL_DOCS:', e);
return false;
}
});
const isLoading = ref(false);
// Удалять может ТОЛЬКО редактор (MANAGE_LEGAL_DOCS)
const canDeletePage = computed(() => {
const hasPermission = canManageLegalDocs.value;
const hasAddress = !!address.value;
console.log('[PageView] canDeletePage проверка:', { hasPermission, hasAddress, address: address.value });
return hasPermission && hasAddress;
});
// Редактировать может редактор или пользователь с правом редактирования
const canEditPage = computed(() => {
if (!address.value) return false;
return canManageLegalDocs.value || canEditData.value;
});
// Методы
function goToEdit() {
router.push({ name: 'content-create', query: { edit: route.params.id } });
@@ -202,14 +229,22 @@ async function reindex() {
}
async function deletePage() {
if (confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить.')) {
// Дополнительная проверка прав на стороне клиента
if (!canManageLegalDocs.value) {
alert('У вас нет прав для удаления страниц. Требуются права редактора.');
return;
}
if (!confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить. Все связанные файлы также будут удалены.')) {
return;
}
try {
await pagesService.deletePage(route.params.id);
router.push({ name: 'content-list' });
} catch (error) {
console.error('Ошибка удаления страницы:', error);
alert('Ошибка при удалении страницы');
}
alert('Ошибка при удалении страницы: ' + (error?.response?.data?.error || error?.message || 'Неизвестная ошибка'));
}
}
@@ -281,9 +316,57 @@ async function loadPage() {
}
}
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.page-content video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
// Загрузка данных
onMounted(() => {
loadPage();
setupVideoErrorHandlers();
});
</script>

View File

@@ -106,7 +106,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
@@ -199,9 +199,57 @@ async function loadPage() {
}
}
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.content-block video, .page-content video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
// Загрузка данных
onMounted(() => {
loadPage();
setupVideoErrorHandlers();
});
</script>