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

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

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