ваше сообщение коммита
This commit is contained in:
@@ -141,6 +141,17 @@ http {
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
}
|
||||
|
||||
# Pre-rendered blog pages (SEO optimization)
|
||||
location /blog {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri.html /blog/index.html /index.html;
|
||||
|
||||
# Заголовки для SEO
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# Статические файлы
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<router-link to="/" class="nav-link-btn" active-class="active">
|
||||
<span>Чат</span>
|
||||
</router-link>
|
||||
<router-link to="/content/published" class="nav-link-btn" active-class="active">
|
||||
<router-link to="/blog" class="nav-link-btn" active-class="active">
|
||||
<span>Блог</span>
|
||||
</router-link>
|
||||
<router-link to="/crm" class="nav-link-btn" active-class="active">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Заголовок страницы -->
|
||||
<header v-if="page" class="page-header">
|
||||
<div class="page-header-top">
|
||||
<button class="back-btn" @click="$emit('back')" title="Вернуться к списку">
|
||||
<button v-if="!hideBackButton" class="back-btn" @click="$emit('back')" title="Вернуться к списку">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
@@ -95,7 +95,7 @@
|
||||
</article>
|
||||
|
||||
<!-- Навигация: Предыдущая/Следующая -->
|
||||
<nav v-if="navigation" class="page-navigation">
|
||||
<nav v-if="page && navigation" class="page-navigation">
|
||||
<div class="nav-section">
|
||||
<a
|
||||
v-if="navigation.previous"
|
||||
@@ -140,18 +140,21 @@
|
||||
</nav>
|
||||
|
||||
<!-- Загрузка -->
|
||||
<div v-else-if="isLoading" class="loading-state">
|
||||
<div v-if="!page && isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Загрузка документа...</p>
|
||||
</div>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<div v-else class="error-state">
|
||||
<div v-else-if="!page && !isLoading" class="error-state">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<h3>Документ не найден</h3>
|
||||
<p>Запрашиваемый документ не существует или не опубликован</p>
|
||||
<p v-if="route.path.startsWith('/blog')" class="error-hint">
|
||||
<small>Проверьте консоль браузера для деталей ошибки. Убедитесь, что страница опубликована и отмечена для отображения в блоге.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,8 +171,12 @@ import { PERMISSIONS } from '../../composables/permissions';
|
||||
|
||||
const props = defineProps({
|
||||
pageId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
hideBackButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,7 +211,24 @@ function updateMetaTags(pageData) {
|
||||
const title = seoData?.title || pageData.title || 'Документ';
|
||||
const description = seoData?.description || pageData.summary || '';
|
||||
const keywords = seoData?.keywords || '';
|
||||
const canonicalUrl = `${window.location.origin}/content/published?page=${pageData.id}`;
|
||||
|
||||
// Определяем canonical URL в зависимости от текущего маршрута и наличия slug
|
||||
const currentPath = window.location.pathname;
|
||||
let canonicalUrl;
|
||||
if (currentPath.startsWith('/blog')) {
|
||||
// Используем slug если есть, иначе fallback на query параметр
|
||||
if (pageData.slug && typeof pageData.slug === 'string' && pageData.slug.trim() !== '') {
|
||||
canonicalUrl = `${window.location.origin}/blog/${encodeURIComponent(pageData.slug)}`;
|
||||
} else if (pageData.id) {
|
||||
canonicalUrl = `${window.location.origin}/blog?page=${pageData.id}`;
|
||||
} else {
|
||||
canonicalUrl = `${window.location.origin}/blog`;
|
||||
}
|
||||
} else {
|
||||
canonicalUrl = pageData.id
|
||||
? `${window.location.origin}/content/published?page=${pageData.id}`
|
||||
: `${window.location.origin}/content/published`;
|
||||
}
|
||||
|
||||
// Обновляем title
|
||||
document.title = title;
|
||||
@@ -246,6 +270,56 @@ function updateMetaTags(pageData) {
|
||||
|
||||
// Robots meta
|
||||
updateOrCreateMeta('robots', 'index, follow');
|
||||
|
||||
// Добавляем JSON-LD разметку для статьи
|
||||
addArticleJsonLd(pageData, canonicalUrl);
|
||||
}
|
||||
|
||||
// Добавляем JSON-LD разметку для статьи
|
||||
function addArticleJsonLd(pageData, canonicalUrl) {
|
||||
// Удаляем старую разметку, если есть
|
||||
const oldScript = document.querySelector('script[type="application/ld+json"][data-article]');
|
||||
if (oldScript) {
|
||||
oldScript.remove();
|
||||
}
|
||||
|
||||
// Парсим seo данные
|
||||
let seoData = pageData.seo;
|
||||
if (typeof seoData === 'string') {
|
||||
try {
|
||||
seoData = JSON.parse(seoData);
|
||||
} catch (e) {
|
||||
seoData = null;
|
||||
}
|
||||
}
|
||||
|
||||
const articleJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': seoData?.title || pageData.title || '',
|
||||
'description': seoData?.description || pageData.summary || '',
|
||||
'datePublished': pageData.created_at || '',
|
||||
'dateModified': pageData.updated_at || pageData.created_at || '',
|
||||
'url': canonicalUrl,
|
||||
'author': {
|
||||
'@type': 'Organization',
|
||||
'name': 'Digital Legal Entity'
|
||||
},
|
||||
'publisher': {
|
||||
'@type': 'Organization',
|
||||
'name': 'Digital Legal Entity'
|
||||
}
|
||||
};
|
||||
|
||||
if (pageData.category) {
|
||||
articleJsonLd.articleSection = pageData.category;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.setAttribute('data-article', 'true');
|
||||
script.textContent = JSON.stringify(articleJsonLd);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Загрузка страницы
|
||||
@@ -254,27 +328,138 @@ async function loadPage() {
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
page.value = await pagesService.getPublicPage(props.pageId);
|
||||
|
||||
// Устанавливаем мета-теги для SEO
|
||||
if (page.value) {
|
||||
updateMetaTags(page.value);
|
||||
// Определяем, это slug или id
|
||||
// Проверяем, находимся ли мы на странице блога
|
||||
const isBlogRoute = route.path.startsWith('/blog');
|
||||
|
||||
// Если это строка и не чисто число, или мы на странице блога - считаем это slug
|
||||
const isSlug = isBlogRoute || (typeof props.pageId === 'string' && !/^\d+$/.test(props.pageId));
|
||||
|
||||
console.log('[DocsContent] loadPage:', {
|
||||
pageId: props.pageId,
|
||||
isBlogRoute,
|
||||
isSlug,
|
||||
routePath: route.path
|
||||
});
|
||||
|
||||
if (isSlug) {
|
||||
// Загружаем по slug через новый endpoint блога
|
||||
// Если pageId это число, но мы на странице блога, конвертируем в строку
|
||||
const slug = typeof props.pageId === 'string' ? props.pageId : String(props.pageId);
|
||||
console.log('[DocsContent] Загрузка по slug:', slug);
|
||||
|
||||
try {
|
||||
const response = await pagesService.getBlogPageBySlug(slug);
|
||||
console.log('[DocsContent] Ответ от API:', {
|
||||
hasData: !!response,
|
||||
type: typeof response,
|
||||
keys: response ? Object.keys(response) : [],
|
||||
hasTitle: response?.title,
|
||||
hasContent: !!response?.content,
|
||||
id: response?.id
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
console.error('[DocsContent] API вернул пустой ответ');
|
||||
throw new Error('Страница не найдена');
|
||||
}
|
||||
|
||||
// Устанавливаем page.value ДО любых других операций
|
||||
page.value = response;
|
||||
console.log('[DocsContent] page.value установлен:', {
|
||||
hasPage: !!page.value,
|
||||
hasTitle: !!page.value?.title,
|
||||
hasContent: !!page.value?.content,
|
||||
pageValue: page.value
|
||||
});
|
||||
|
||||
// Проверяем, что page.value действительно установлен
|
||||
if (!page.value) {
|
||||
console.error('[DocsContent] КРИТИЧЕСКАЯ ОШИБКА: page.value не установлен после присваивания!');
|
||||
}
|
||||
} catch (slugError) {
|
||||
console.error('[DocsContent] Ошибка загрузки по slug:', slugError);
|
||||
console.error('[DocsContent] Детали ошибки:', {
|
||||
message: slugError.message,
|
||||
response: slugError.response?.data,
|
||||
status: slugError.response?.status
|
||||
});
|
||||
// Если ошибка 404, пробуем найти страницу по другому способу
|
||||
if (slugError.response?.status === 404) {
|
||||
console.warn('[DocsContent] Страница не найдена по slug, возможно slug не совпадает');
|
||||
}
|
||||
throw slugError;
|
||||
}
|
||||
} else {
|
||||
// Загружаем по id (старый способ)
|
||||
const pageId = typeof props.pageId === 'number' ? props.pageId : parseInt(props.pageId, 10);
|
||||
console.log('[DocsContent] Загрузка по id:', pageId);
|
||||
page.value = await pagesService.getPublicPage(pageId);
|
||||
}
|
||||
|
||||
// Загружаем навигацию
|
||||
// Устанавливаем мета-теги для SEO (оборачиваем в try-catch, чтобы ошибка не блокировала отображение)
|
||||
if (page.value) {
|
||||
console.log('[DocsContent] Устанавливаем мета-теги для страницы:', {
|
||||
id: page.value.id,
|
||||
title: page.value.title,
|
||||
hasContent: !!page.value.content
|
||||
});
|
||||
try {
|
||||
updateMetaTags(page.value);
|
||||
} catch (metaError) {
|
||||
console.error('[DocsContent] Ошибка установки мета-тегов (не критично):', metaError);
|
||||
// Продолжаем работу, даже если мета-теги не установились
|
||||
}
|
||||
} else {
|
||||
console.error('[DocsContent] page.value пусто после загрузки!', {
|
||||
response: 'данные не были установлены',
|
||||
pageId: props.pageId,
|
||||
isSlug
|
||||
});
|
||||
}
|
||||
|
||||
// Загружаем навигацию (только для загрузки по ID, не по slug)
|
||||
if (!isSlug && page.value && page.value.id) {
|
||||
try {
|
||||
navigation.value = await pagesService.getPublicPageNavigation(props.pageId);
|
||||
navigation.value = await pagesService.getPublicPageNavigation(page.value.id);
|
||||
breadcrumbs.value = navigation.value.breadcrumbs || [];
|
||||
} catch (navError) {
|
||||
console.warn('Ошибка загрузки навигации:', navError);
|
||||
navigation.value = null;
|
||||
breadcrumbs.value = [];
|
||||
}
|
||||
} else {
|
||||
// Для статей блога навигация не нужна
|
||||
navigation.value = null;
|
||||
breadcrumbs.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки страницы:', error);
|
||||
console.error('[DocsContent] Ошибка загрузки страницы:', error);
|
||||
console.error('[DocsContent] Детали ошибки:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
pageId: props.pageId,
|
||||
routePath: route.path,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Если это не критическая ошибка (например, 404), все равно пытаемся установить page.value
|
||||
// если данные есть в response
|
||||
if (error.response?.data && error.response.status !== 404) {
|
||||
console.warn('[DocsContent] Пытаемся использовать данные из error.response.data');
|
||||
page.value = error.response.data;
|
||||
} else {
|
||||
page.value = null;
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
console.log('[DocsContent] loadPage завершен:', {
|
||||
hasPage: !!page.value,
|
||||
isLoading: isLoading.value,
|
||||
pageId: props.pageId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,13 +525,18 @@ const formatContent = computed(() => {
|
||||
|
||||
// Конфигурация DOMPurify для разрешения медиа-контента
|
||||
const sanitizeConfig = {
|
||||
ADD_TAGS: ['video', 'source', 'img', 'iframe'],
|
||||
ADD_TAGS: ['video', 'source', 'img', 'iframe', 'pre', 'code'],
|
||||
ADD_ATTR: [
|
||||
'controls', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'playsinline',
|
||||
'src', 'alt', 'title', 'width', 'height', 'style', 'class', 'loading',
|
||||
'frameborder', 'allowfullscreen', 'allow'
|
||||
],
|
||||
ALLOW_DATA_ATTR: true
|
||||
ALLOW_DATA_ATTR: true,
|
||||
// Сохраняем пробелы в HTML
|
||||
KEEP_CONTENT: true,
|
||||
// Не удаляем пробелы внутри тегов
|
||||
FORBID_TAGS: [],
|
||||
FORBID_ATTR: []
|
||||
};
|
||||
|
||||
// Проверяем, является ли контент HTML (содержит HTML теги)
|
||||
@@ -381,9 +571,10 @@ const formatContent = computed(() => {
|
||||
return match; // Оставляем заголовок
|
||||
});
|
||||
|
||||
// Удаляем пустые строки и теги в начале
|
||||
sanitizedHtml = sanitizedHtml.replace(/^\s*(<br\s*\/?>|<p>\s*<\/p>)\s*/i, '');
|
||||
sanitizedHtml = sanitizedHtml.trim();
|
||||
// Удаляем только пустые теги в начале, но сохраняем пробелы внутри контента
|
||||
sanitizedHtml = sanitizedHtml.replace(/^(<br\s*\/?>|<p>\s*<\/p>)+/i, '');
|
||||
// Обрезаем только пробелы в самом начале и конце, но не внутри
|
||||
sanitizedHtml = sanitizedHtml.replace(/^\s+/, '').replace(/\s+$/, '');
|
||||
|
||||
return sanitizedHtml;
|
||||
} else if (isHtml) {
|
||||
@@ -411,7 +602,8 @@ const formatContent = computed(() => {
|
||||
return match; // Оставляем заголовок
|
||||
});
|
||||
|
||||
sanitizedHtml = sanitizedHtml.trim();
|
||||
// Обрезаем только пробелы в самом начале и конце, но не внутри
|
||||
sanitizedHtml = sanitizedHtml.replace(/^\s+/, '').replace(/\s+$/, '');
|
||||
return sanitizedHtml;
|
||||
} else {
|
||||
// Для обычного текста также удаляем первую строку, если она совпадает с заголовком
|
||||
@@ -545,6 +737,18 @@ function setupVideoErrorHandlers() {
|
||||
});
|
||||
}
|
||||
|
||||
// Отслеживание изменений page для диагностики
|
||||
watch(() => page.value, (newPage, oldPage) => {
|
||||
console.log('[DocsContent] page.value изменился:', {
|
||||
wasNull: oldPage === null,
|
||||
isNull: newPage === null,
|
||||
hasTitle: !!newPage?.title,
|
||||
hasContent: !!newPage?.content,
|
||||
pageId: newPage?.id,
|
||||
slug: newPage?.slug
|
||||
});
|
||||
}, { deep: true });
|
||||
|
||||
// Отслеживание изменений контента для добавления обработчиков ошибок
|
||||
watch(() => page.value?.content, () => {
|
||||
if (page.value?.content) {
|
||||
|
||||
@@ -29,6 +29,16 @@ const routes = [
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/blog',
|
||||
name: 'blog',
|
||||
component: () => import('../views/BlogView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/blog/:slug',
|
||||
name: 'blog-article',
|
||||
component: () => import('../views/BlogView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/crm',
|
||||
name: 'crm',
|
||||
|
||||
@@ -62,6 +62,27 @@ export default {
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
async getBlogPages(params = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.category) queryParams.append('category', params.category);
|
||||
if (params.search) queryParams.append('search', params.search);
|
||||
|
||||
const url = `/pages/blog/all${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
const res = await api.get(url);
|
||||
return res.data;
|
||||
},
|
||||
async getBlogPageBySlug(slug) {
|
||||
console.log('[pagesService] getBlogPageBySlug:', slug);
|
||||
const res = await api.get(`/pages/blog/${encodeURIComponent(slug)}`);
|
||||
console.log('[pagesService] getBlogPageBySlug response:', {
|
||||
status: res.status,
|
||||
hasData: !!res.data,
|
||||
dataKeys: res.data ? Object.keys(res.data) : [],
|
||||
id: res.data?.id,
|
||||
title: res.data?.title
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
async getPublicPagesStructure() {
|
||||
const res = await api.get('/pages/public/structure');
|
||||
return res.data;
|
||||
|
||||
503
frontend/src/views/BlogView.vue
Normal file
503
frontend/src/views/BlogView.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<!--
|
||||
Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
All rights reserved.
|
||||
|
||||
This software is proprietary and confidential.
|
||||
Unauthorized copying, modification, or distribution is prohibited.
|
||||
|
||||
For licensing inquiries: info@hb3-accelerator.com
|
||||
Website: https://hb3-accelerator.com
|
||||
GitHub: https://github.com/VC-HB3-Accelerator
|
||||
-->
|
||||
|
||||
<template>
|
||||
<BaseLayout
|
||||
:is-authenticated="isAuthenticated"
|
||||
:identities="identities"
|
||||
:token-balances="tokenBalances"
|
||||
:is-loading-tokens="isLoadingTokens"
|
||||
@auth-action-completed="$emit('auth-action-completed')"
|
||||
>
|
||||
<div class="blog-page">
|
||||
<!-- Если открыта отдельная статья, показываем только её -->
|
||||
<div v-if="currentPageId || currentSlug" class="article-view">
|
||||
<DocsContent :page-id="currentSlug || currentPageId" :hide-back-button="true" @back="goToIndex" />
|
||||
</div>
|
||||
|
||||
<!-- Иначе показываем список статей -->
|
||||
<template v-else>
|
||||
<!-- Загрузка -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Загрузка статей...</p>
|
||||
</div>
|
||||
|
||||
<!-- Пустое состояние -->
|
||||
<div v-else-if="filteredPages.length === 0" class="empty-state">
|
||||
<div class="empty-icon"><i class="fas fa-book-open"></i></div>
|
||||
<h3>Нет статей в блоге</h3>
|
||||
<p>Статьи появятся здесь после их публикации редакторами</p>
|
||||
</div>
|
||||
|
||||
<!-- Список статей -->
|
||||
<div v-else class="blog-articles">
|
||||
<article
|
||||
v-for="page in filteredPages"
|
||||
:key="page.id"
|
||||
class="blog-article"
|
||||
@click="openArticle(page)"
|
||||
>
|
||||
<div class="article-header">
|
||||
<h2 class="article-title">{{ page.title }}</h2>
|
||||
<div v-if="page.category" class="article-category">
|
||||
<i class="fas fa-folder"></i>
|
||||
{{ formatCategoryName(page.category) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="page.summary" class="article-summary">
|
||||
{{ page.summary }}
|
||||
</div>
|
||||
|
||||
<div class="article-meta">
|
||||
<span class="article-date">
|
||||
<i class="fas fa-calendar"></i>
|
||||
{{ formatDate(page.created_at) }}
|
||||
</span>
|
||||
<span class="article-read-more">
|
||||
Читать далее →
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import BaseLayout from '../components/BaseLayout.vue';
|
||||
import DocsContent from '../components/docs/DocsContent.vue';
|
||||
import pagesService from '../services/pagesService';
|
||||
|
||||
const props = defineProps({
|
||||
isAuthenticated: { type: Boolean, default: false },
|
||||
identities: { type: Array, default: () => [] },
|
||||
tokenBalances: { type: Object, default: () => ({}) },
|
||||
isLoadingTokens: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['auth-action-completed']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const pages = ref([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const currentSlug = computed(() => {
|
||||
return route.params.slug || null;
|
||||
});
|
||||
|
||||
const currentPageId = computed(() => {
|
||||
// Если есть slug, используем его для загрузки страницы
|
||||
if (currentSlug.value) {
|
||||
return currentSlug.value; // Временно используем slug как идентификатор
|
||||
}
|
||||
|
||||
// Fallback на старый способ через query параметр
|
||||
const queryPage = route.query.page;
|
||||
if (queryPage) {
|
||||
const pageId = typeof queryPage === 'string' ? parseInt(queryPage, 10) : queryPage;
|
||||
if (!isNaN(pageId)) {
|
||||
return pageId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const filteredPages = computed(() => {
|
||||
return pages.value;
|
||||
});
|
||||
|
||||
function formatCategoryName(name) {
|
||||
if (name === 'uncategorized') return 'Без категории';
|
||||
if (!name || name.length === 0) return name;
|
||||
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function openArticle(page) {
|
||||
// Проверяем, что page - это объект
|
||||
if (!page || typeof page !== 'object') {
|
||||
console.error('[BlogView] openArticle: невалидный объект страницы');
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем slug если есть, иначе fallback на id
|
||||
if (page.slug && typeof page.slug === 'string' && page.slug.trim() !== '') {
|
||||
router.push({ name: 'blog-article', params: { slug: page.slug.trim() } }).catch(err => {
|
||||
console.error('[BlogView] Ошибка навигации:', err);
|
||||
});
|
||||
} else if (page.id) {
|
||||
// Fallback на старый способ через query параметр
|
||||
router.push({ name: 'blog', query: { page: page.id } }).catch(err => {
|
||||
console.error('[BlogView] Ошибка навигации:', err);
|
||||
});
|
||||
} else {
|
||||
console.error('[BlogView] openArticle: у страницы нет ни slug, ни id');
|
||||
}
|
||||
}
|
||||
|
||||
function goToIndex() {
|
||||
router.push({ name: 'blog' });
|
||||
}
|
||||
|
||||
|
||||
async function loadPages() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const loadedPages = await pagesService.getBlogPages();
|
||||
|
||||
if (!Array.isArray(loadedPages)) {
|
||||
console.error('[BlogView] loadedPages не является массивом:', typeof loadedPages, loadedPages);
|
||||
pages.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
pages.value = loadedPages;
|
||||
} catch (e) {
|
||||
console.error('[BlogView] Ошибка загрузки страниц:', e);
|
||||
pages.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Установка мета-тегов для страницы блога
|
||||
function updateBlogMetaTags() {
|
||||
const title = 'Блог';
|
||||
const description = 'Публикации и статьи';
|
||||
const canonicalUrl = `${window.location.origin}/blog`;
|
||||
|
||||
// Обновляем title
|
||||
document.title = title;
|
||||
|
||||
// Обновляем или создаем meta теги
|
||||
const updateOrCreateMeta = (name, content, attribute = 'name') => {
|
||||
if (!content) return;
|
||||
let meta = document.querySelector(`meta[${attribute}="${name}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute(attribute, name);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', content);
|
||||
};
|
||||
|
||||
// Meta description
|
||||
updateOrCreateMeta('description', description);
|
||||
|
||||
// Canonical URL
|
||||
let canonical = document.querySelector('link[rel="canonical"]');
|
||||
if (!canonical) {
|
||||
canonical = document.createElement('link');
|
||||
canonical.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(canonical);
|
||||
}
|
||||
canonical.setAttribute('href', canonicalUrl);
|
||||
|
||||
// Open Graph теги для социальных сетей
|
||||
updateOrCreateMeta('og:title', title, 'property');
|
||||
updateOrCreateMeta('og:description', description, 'property');
|
||||
updateOrCreateMeta('og:type', 'website', 'property');
|
||||
updateOrCreateMeta('og:url', canonicalUrl, 'property');
|
||||
|
||||
// Robots meta
|
||||
updateOrCreateMeta('robots', 'index, follow');
|
||||
}
|
||||
|
||||
// Добавляем JSON-LD разметку для списка статей
|
||||
function addBlogJsonLd() {
|
||||
// Удаляем старую разметку, если есть
|
||||
const oldScript = document.querySelector('script[type="application/ld+json"][data-blog-list]');
|
||||
if (oldScript) {
|
||||
oldScript.remove();
|
||||
}
|
||||
|
||||
if (pages.value.length === 0) return;
|
||||
|
||||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
'name': 'Блог',
|
||||
'description': 'Публикации и статьи',
|
||||
'url': `${window.location.origin}/blog`,
|
||||
'blogPost': pages.value.slice(0, 10).map(page => {
|
||||
const url = (page.slug && typeof page.slug === 'string' && page.slug.trim() !== '')
|
||||
? `${window.location.origin}/blog/${encodeURIComponent(page.slug)}`
|
||||
: (page.id ? `${window.location.origin}/blog?page=${page.id}` : `${window.location.origin}/blog`);
|
||||
|
||||
return {
|
||||
'@type': 'BlogPosting',
|
||||
'headline': page.title || '',
|
||||
'description': page.summary || '',
|
||||
'datePublished': page.created_at || '',
|
||||
'url': url
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.setAttribute('data-blog-list', 'true');
|
||||
script.textContent = JSON.stringify(blogJsonLd);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Следим за изменением currentPageId/currentSlug и обновляем мета-теги
|
||||
watch(() => currentPageId.value || currentSlug.value, (newId) => {
|
||||
if (!newId) {
|
||||
// Если вернулись к списку, обновляем мета-теги для списка
|
||||
updateBlogMetaTags();
|
||||
addBlogJsonLd();
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем JSON-LD при загрузке страниц
|
||||
watch(() => pages.value, () => {
|
||||
if (!currentPageId.value && !currentSlug.value) {
|
||||
addBlogJsonLd();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPages();
|
||||
|
||||
// Устанавливаем мета-теги только если не открыта отдельная статья
|
||||
if (!currentPageId.value && !currentSlug.value) {
|
||||
updateBlogMetaTags();
|
||||
addBlogJsonLd();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid var(--color-light, #f3f3f3);
|
||||
border-top: 3px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--color-grey, #6c757d);
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--color-primary);
|
||||
margin: 0 0 10px 0;
|
||||
font-size: var(--font-size-xl, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--color-grey, #6c757d);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md, 14px);
|
||||
}
|
||||
|
||||
.blog-articles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
background: var(--color-white, #fff);
|
||||
border: 1px solid var(--color-border, #e9ecef);
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-sm, 0 2px 4px rgba(0, 0, 0, 0.05));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.blog-article:hover {
|
||||
box-shadow: var(--shadow-lg, 0 8px 16px rgba(0, 0, 0, 0.1));
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--color-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.blog-article:hover .article-title {
|
||||
color: var(--color-primary-dark, #45a049);
|
||||
}
|
||||
|
||||
.article-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-grey, #6c757d);
|
||||
background: var(--color-light, #f8f9fa);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
color: var(--color-text, #495057);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
font-size: var(--font-size-md, 14px);
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-grey, #6c757d);
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--color-border, #e9ecef);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.article-read-more {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.blog-article:hover .article-read-more {
|
||||
color: var(--color-primary-dark, #45a049);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.article-view {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--color-light, #f8f9fa);
|
||||
border: 1px solid var(--color-border, #e9ecef);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text, #495057);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--color-grey-light, #e9ecef);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-page {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.blog-articles {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.blog-page {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,19 @@
|
||||
<option value="image" disabled>Изображение (PNG/JPG) — скоро</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.visibility === 'public'">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="form.showInBlog"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span>Показывать в блоге</span>
|
||||
</label>
|
||||
<p class="form-hint">
|
||||
Если отмечено, страница будет отображаться на странице блога (/blog)
|
||||
</p>
|
||||
</div>
|
||||
<p class="form-hint">
|
||||
Для HTML-постов переменные подставляются при рендере. Реквизиты заполняются на странице настроек контента.
|
||||
</p>
|
||||
@@ -182,7 +195,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import BaseLayout from '../components/BaseLayout.vue';
|
||||
import RichTextEditor from '../components/editor/RichTextEditor.vue';
|
||||
@@ -244,7 +257,8 @@ const form = ref({
|
||||
visibility: 'public',
|
||||
requiredPermission: '',
|
||||
format: 'html',
|
||||
category: ''
|
||||
category: '',
|
||||
showInBlog: false
|
||||
});
|
||||
|
||||
// Список категорий
|
||||
@@ -376,6 +390,7 @@ async function loadPageForEdit() {
|
||||
form.value.requiredPermission = page.required_permission || '';
|
||||
form.value.format = page.format || 'html';
|
||||
form.value.category = page.category || '';
|
||||
form.value.showInBlog = page.show_in_blog === true || page.show_in_blog === 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки страницы для редактирования:', error);
|
||||
@@ -395,7 +410,9 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
if (form.value.format === 'html') {
|
||||
if (!form.value.content.trim()) {
|
||||
// Проверяем, что контент не пустой (учитываем только видимый текст, без HTML тегов)
|
||||
const textContent = form.value.content.replace(/<[^>]*>/g, '').trim();
|
||||
if (!textContent) {
|
||||
alert('Заполните контент страницы!');
|
||||
return;
|
||||
}
|
||||
@@ -416,7 +433,9 @@ async function handleSubmit() {
|
||||
const pageData = {
|
||||
title: form.value.title.trim(),
|
||||
summary: form.value.summary.trim(),
|
||||
content: form.value.content.trim(),
|
||||
// Сохраняем контент без обрезки пробелов в начале/конце, чтобы сохранить форматирование
|
||||
// Удаляем только пробелы в самом начале и конце, но сохраняем пробелы внутри
|
||||
content: form.value.content.replace(/^\s+/, '').replace(/\s+$/, ''),
|
||||
seo: form.value.seo,
|
||||
status: form.value.status,
|
||||
settings: form.value.settings,
|
||||
@@ -427,7 +446,8 @@ async function handleSubmit() {
|
||||
format: form.value.format,
|
||||
mime_type: 'text/html',
|
||||
storage_type: 'embedded',
|
||||
category: form.value.category || null
|
||||
category: form.value.category || null,
|
||||
show_in_blog: form.value.visibility === 'public' ? form.value.showInBlog : false
|
||||
};
|
||||
page = await pagesService.updatePage(editId.value, pageData);
|
||||
} else {
|
||||
@@ -449,6 +469,11 @@ async function handleSubmit() {
|
||||
fd.append('required_permission', '');
|
||||
}
|
||||
fd.append('format', form.value.format);
|
||||
if (form.value.visibility === 'public') {
|
||||
fd.append('show_in_blog', form.value.showInBlog ? 'true' : 'false');
|
||||
} else {
|
||||
fd.append('show_in_blog', 'false');
|
||||
}
|
||||
if (fileBlob.value) {
|
||||
fd.append('file', fileBlob.value);
|
||||
}
|
||||
@@ -460,7 +485,9 @@ async function handleSubmit() {
|
||||
const pageData = {
|
||||
title: form.value.title.trim(),
|
||||
summary: form.value.summary.trim(),
|
||||
content: form.value.content.trim(),
|
||||
// Сохраняем контент без обрезки пробелов в начале/конце, чтобы сохранить форматирование
|
||||
// Удаляем только пробелы в самом начале и конце, но сохраняем пробелы внутри
|
||||
content: form.value.content.replace(/^\s+/, '').replace(/\s+$/, ''),
|
||||
seo: form.value.seo,
|
||||
status: form.value.status,
|
||||
settings: form.value.settings,
|
||||
@@ -471,7 +498,8 @@ async function handleSubmit() {
|
||||
format: form.value.format,
|
||||
mime_type: 'text/html',
|
||||
storage_type: 'embedded',
|
||||
category: form.value.category || null
|
||||
category: form.value.category || null,
|
||||
show_in_blog: form.value.visibility === 'public' ? form.value.showInBlog : false
|
||||
};
|
||||
page = await pagesService.createPage(pageData);
|
||||
} else {
|
||||
@@ -493,6 +521,11 @@ async function handleSubmit() {
|
||||
fd.append('required_permission', '');
|
||||
}
|
||||
fd.append('format', form.value.format);
|
||||
if (form.value.visibility === 'public') {
|
||||
fd.append('show_in_blog', form.value.showInBlog ? 'true' : 'false');
|
||||
} else {
|
||||
fd.append('show_in_blog', 'false');
|
||||
}
|
||||
fd.append('file', fileBlob.value);
|
||||
page = await pagesService.createPage(fd, true);
|
||||
}
|
||||
@@ -512,6 +545,13 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за изменением видимости и сбрасываем showInBlog для internal страниц
|
||||
watch(() => form.value.visibility, (newVisibility) => {
|
||||
if (newVisibility === 'internal') {
|
||||
form.value.showInBlog = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка данных при монтировании
|
||||
onMounted(async () => {
|
||||
// Проверяем права доступа
|
||||
|
||||
Reference in New Issue
Block a user