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

This commit is contained in:
2025-12-24 14:46:30 +03:00
parent c02d0a38ac
commit 37d6072cf2
11 changed files with 1956 additions and 57 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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;

View 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>

View File

@@ -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 () => {
// Проверяем права доступа