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

This commit is contained in:
2026-01-13 20:14:20 +03:00
parent 77f1cea967
commit e93b9f3a8f
6 changed files with 484 additions and 17 deletions

6
.gitignore vendored
View File

@@ -213,3 +213,9 @@ sync-to-vds.sh
# Database initialization helper script
scripts/internal/db/db_init_helper.sh
# Blog content (будет перенесен в блог приложения)
blog-content/
# Public docs (загружены в БД)
public-docs/

View File

@@ -1590,6 +1590,107 @@ router.get('/blog/:slug', async (req, res) => {
}
});
// Получить публичную страницу по slug (для /content/published)
router.get('/published/:slug', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
let slug = req.params.slug;
// Декодируем slug (на случай если он был закодирован)
try {
slug = decodeURIComponent(slug);
} catch (e) {
console.warn('[pages] Ошибка декодирования slug:', e.message);
}
// Валидация slug
if (!slug || typeof slug !== 'string' || slug.trim() === '') {
return res.status(400).json({ error: 'Невалидный slug' });
}
slug = slug.trim();
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.status(404).json({ error: 'Страница не найдена' });
}
// Получаем страницу по slug (без условия show_in_blog)
const { rows } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE slug = $1
AND visibility = 'public'
AND status = 'published'
LIMIT 1`,
[slug]
);
console.log(`[pages] GET /published/:slug: поиск по slug "${slug}", найдено строк: ${rows.length}`);
if (rows.length === 0) {
// Пробуем найти страницу без учета регистра
const { rows: rowsCaseInsensitive } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE LOWER(TRIM(slug)) = LOWER(TRIM($1))
AND visibility = 'public'
AND status = 'published'
LIMIT 1`,
[slug]
);
if (rowsCaseInsensitive.length > 0) {
console.log(`[pages] GET /published/:slug: найдено с учетом регистра, slug в БД: "${rowsCaseInsensitive[0].slug}"`);
return res.json(rowsCaseInsensitive[0]);
}
return res.status(404).json({ error: 'Страница не найдена' });
}
console.log(`[pages] GET /published/:slug: страница найдена, id: ${rows[0].id}, slug: ${rows[0].slug}`);
// Расшифровываем зашифрованные поля
const page = rows[0];
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Создаем объект с расшифрованными данными
const decryptedPage = { ...page };
// Расшифровываем поля, если они зашифрованы
const fieldsToDecrypt = ['title', 'summary', 'content', 'seo', 'settings'];
for (const field of fieldsToDecrypt) {
const encryptedField = `${field}_encrypted`;
if (page[encryptedField]) {
try {
const decryptResult = await db.getQuery()(
`SELECT decrypt_text($1, $2) as ${field}`,
[page[encryptedField], encryptionKey]
);
if (decryptResult.rows[0] && decryptResult.rows[0][field] !== null) {
decryptedPage[field] = decryptResult.rows[0][field];
}
} catch (decryptError) {
console.warn(`[pages] GET /published/:slug: ошибка расшифровки поля ${field}:`, decryptError.message);
if (page[field]) {
decryptedPage[field] = page[field];
}
}
} else if (page[field]) {
decryptedPage[field] = page[field];
}
}
res.json(decryptedPage);
} catch (error) {
console.error('Ошибка получения публичной страницы по slug:', error);
res.status(500).json({ error: 'Ошибка получения страницы' });
}
});
// Получить иерархическую структуру всех публичных страниц
router.get('/public/structure', async (req, res) => {
try {
@@ -1903,7 +2004,12 @@ router.get('/internal/all', async (req, res) => {
});
// Получить одну опубликованную страницу по id
router.get('/public/:id', async (req, res) => {
router.get('/public/:id', async (req, res, next) => {
// Пропускаем специальные endpoints
if (req.params.id === 'robots.txt' || req.params.id === 'sitemap.xml') {
return next();
}
try {
const tableName = `admin_pages_simple`;
@@ -1950,7 +2056,7 @@ Disallow: /admin/
Disallow: /content/create
Disallow: /content/edit
Sitemap: ${baseUrl}/pages/public/sitemap.xml
Sitemap: ${baseUrl}/api/pages/public/sitemap.xml
`;
res.setHeader('Content-Type', 'text/plain');
@@ -2005,7 +2111,8 @@ router.get('/public/sitemap.xml', async (req, res) => {
// Добавляем страницы блога с использованием slug
for (const page of blogPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const dateObj = page.updated_at || page.created_at || new Date();
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
const pageUrl = page.slug
? `${baseUrl}/blog/${page.slug}`
: `${baseUrl}/blog?page=${page.id}`;
@@ -2021,22 +2128,25 @@ router.get('/public/sitemap.xml', async (req, res) => {
// Получаем остальные публичные страницы (без show_in_blog)
const { rows: otherPages } = await db.getQuery()(`
SELECT id, updated_at, created_at
SELECT id, slug, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public' AND (show_in_blog IS NULL OR show_in_blog = FALSE)
ORDER BY created_at DESC
`);
// Добавляем остальные публичные страницы
// Добавляем остальные публичные страницы с использованием slug
for (const page of otherPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const pageUrl = `${baseUrl}/content/published?page=${page.id}`;
const dateObj = page.updated_at || page.created_at || new Date();
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
const pageUrl = page.slug
? `${baseUrl}/content/published/${page.slug}`
: `${baseUrl}/content/published?page=${page.id}`;
sitemap += ` <url>
sitemap += ` <url>
<loc>${escapeXml(pageUrl)}</loc>
<lastmod>${lastmod.split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
<priority>0.7</priority>
</url>
`;
}

View File

@@ -139,6 +139,26 @@
</div>
</nav>
<!-- Похожие статьи (только для блога) -->
<section v-if="page && isBlogPage && relatedArticles.length > 0" class="related-articles">
<h3 class="related-title">
<i class="fas fa-newspaper"></i>
Читайте также
</h3>
<div class="related-grid">
<article
v-for="article in relatedArticles"
:key="article.id"
class="related-card"
@click="openRelatedArticle(article)"
>
<h4 class="related-card-title">{{ article.title }}</h4>
<p v-if="article.summary" class="related-card-summary">{{ truncateSummary(article.summary) }}</p>
<span class="related-card-link">Читать </span>
</article>
</div>
</section>
<!-- Загрузка -->
<div v-if="!page && isLoading" class="loading-state">
<div class="loading-spinner"></div>
@@ -177,6 +197,10 @@ const props = defineProps({
hideBackButton: {
type: Boolean,
default: false
},
isPublishedRoute: {
type: Boolean,
default: false
}
});
@@ -192,6 +216,10 @@ const page = ref(null);
const navigation = ref(null);
const breadcrumbs = ref([]);
const isLoading = ref(false);
const relatedArticles = ref([]);
// Определяем, это страница блога
const isBlogPage = computed(() => route.path.startsWith('/blog'));
// Установка мета-тегов для SEO
function updateMetaTags(pageData) {
@@ -330,27 +358,31 @@ async function loadPage() {
isLoading.value = true;
// Определяем, это slug или id
// Проверяем, находимся ли мы на странице блога
// Проверяем, находимся ли мы на странице блога или published
const isBlogRoute = route.path.startsWith('/blog');
const isPublishedSlugRoute = props.isPublishedRoute || route.path.startsWith('/content/published/');
// Если это строка и не чисто число, или мы на странице блога - считаем это slug
const isSlug = isBlogRoute || (typeof props.pageId === 'string' && !/^\d+$/.test(props.pageId));
// Если это строка и не чисто число, или мы на странице блога/published - считаем это slug
const isSlug = isBlogRoute || isPublishedSlugRoute || (typeof props.pageId === 'string' && !/^\d+$/.test(props.pageId));
console.log('[DocsContent] loadPage:', {
pageId: props.pageId,
isBlogRoute,
isPublishedSlugRoute,
isSlug,
routePath: route.path
});
if (isSlug) {
// Загружаем по slug через новый endpoint блога
// Если pageId это число, но мы на странице блога, конвертируем в строку
// Загружаем по slug
const slug = typeof props.pageId === 'string' ? props.pageId : String(props.pageId);
console.log('[DocsContent] Загрузка по slug:', slug);
console.log('[DocsContent] Загрузка по slug:', slug, 'isPublishedSlugRoute:', isPublishedSlugRoute);
try {
const response = await pagesService.getBlogPageBySlug(slug);
// Используем разные endpoints для blog и published
const response = isPublishedSlugRoute
? await pagesService.getPublishedPageBySlug(slug)
: await pagesService.getBlogPageBySlug(slug);
console.log('[DocsContent] Ответ от API:', {
hasData: !!response,
type: typeof response,
@@ -411,6 +443,11 @@ async function loadPage() {
console.error('[DocsContent] Ошибка установки мета-тегов (не критично):', metaError);
// Продолжаем работу, даже если мета-теги не установились
}
// Загружаем похожие статьи для блога
if (isBlogPage.value) {
loadRelatedArticles();
}
} else {
console.error('[DocsContent] page.value пусто после загрузки!', {
response: 'данные не были установлены',
@@ -629,6 +666,38 @@ function formatDate(date) {
});
}
// Загрузка похожих статей для блога
async function loadRelatedArticles() {
if (!isBlogPage.value || !page.value) return;
try {
const allArticles = await pagesService.getBlogPages();
// Исключаем текущую статью и берём до 3 других
relatedArticles.value = allArticles
.filter(article => article.id !== page.value.id)
.slice(0, 3);
} catch (e) {
console.error('[DocsContent] Ошибка загрузки похожих статей:', e);
relatedArticles.value = [];
}
}
// Обрезка summary для карточек
function truncateSummary(text, maxLength = 100) {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
}
// Открытие похожей статьи
function openRelatedArticle(article) {
if (article.slug) {
router.push({ name: 'blog-article', params: { slug: article.slug } });
} else if (article.id) {
router.push({ name: 'blog', query: { page: article.id } });
}
}
function navigateTo(path) {
// Поддержка разных форматов путей
const match1 = path.match(/\/content\/published\/(\d+)/);
@@ -1280,5 +1349,79 @@ onMounted(() => {
justify-content: flex-start;
}
}
/* Похожие статьи */
.related-articles {
margin-top: 48px;
padding-top: 32px;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.related-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #111827);
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.related-title i {
color: var(--primary-color, #3b82f6);
}
.related-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.related-card {
background: var(--bg-secondary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
}
.related-card:hover {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.related-card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #111827);
margin: 0 0 8px 0;
line-height: 1.4;
}
.related-card-summary {
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
margin: 0 0 12px 0;
line-height: 1.5;
}
.related-card-link {
font-size: 0.875rem;
color: var(--primary-color, #3b82f6);
font-weight: 500;
}
@media (max-width: 768px) {
.related-grid {
grid-template-columns: 1fr;
}
.related-articles {
margin-top: 32px;
padding-top: 24px;
}
}
</style>

View File

@@ -213,6 +213,11 @@ const routes = [
name: 'content-published',
component: () => import('../views/content/PublishedListView.vue'),
},
{
path: '/content/published/:slug',
name: 'content-published-slug',
component: () => import('../views/content/PublishedPageView.vue'),
},
{
path: '/content/internal',
name: 'content-internal',

View File

@@ -83,6 +83,17 @@ export default {
});
return res.data;
},
async getPublishedPageBySlug(slug) {
console.log('[pagesService] getPublishedPageBySlug:', slug);
const res = await api.get(`/pages/published/${encodeURIComponent(slug)}`);
console.log('[pagesService] getPublishedPageBySlug response:', {
status: res.status,
hasData: !!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,192 @@
<!--
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="docs-page">
<div class="docs-header">
<button class="close-btn" @click="goBack" title="Закрыть">×</button>
</div>
<!-- Основной контент: сайдбар + контент -->
<div class="docs-layout has-content">
<!-- Сайдбар навигации -->
<DocsSidebar :current-page-id="currentPageId" />
<!-- Основной контент -->
<div class="docs-main">
<DocsContent v-if="pageSlug" :page-id="pageSlug" :is-published-route="true" @back="goToIndex" />
<div v-else class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка документа...</p>
</div>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import DocsSidebar from '../../components/docs/DocsSidebar.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 router = useRouter();
const route = useRoute();
const currentPageId = ref(null);
const pageSlug = computed(() => route.params.slug);
function goBack() {
router.push({ name: 'content-published' });
}
function goToIndex() {
router.push({ name: 'content-published' });
}
// Загружаем ID страницы по slug для сайдбара
onMounted(async () => {
if (pageSlug.value) {
try {
const page = await pagesService.getPublishedPageBySlug(pageSlug.value);
if (page && page.id) {
currentPageId.value = page.id;
}
} catch (error) {
console.error('[PublishedPageView] Ошибка загрузки страницы:', error);
}
}
});
</script>
<style scoped>
.docs-page {
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
overflow: hidden;
}
.docs-header {
display: flex;
justify-content: flex-end;
align-items: flex-start;
padding: 20px;
border-bottom: 2px solid #e9ecef;
background: #fff;
flex-shrink: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: #f0f0f0;
color: #333;
}
.docs-layout {
display: flex;
flex: 1;
overflow: hidden;
background: #fff;
}
.docs-main {
flex: 1;
overflow-y: auto;
background: #fafafa;
}
.loading-state {
text-align: center;
padding: 60px 20px;
}
.loading-spinner {
border: 3px solid #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); }
}
@media (max-width: 1024px) {
.docs-layout {
flex-direction: column;
}
.docs-main {
overflow-y: auto;
min-height: 0;
}
}
@media (max-width: 768px) {
.docs-page {
height: auto;
min-height: calc(100vh - 40px);
overflow: visible;
}
.docs-layout {
flex: 1;
min-height: 0;
overflow: visible;
flex-direction: column;
}
.docs-layout.has-content .docs-sidebar {
display: none;
}
.docs-main {
overflow-y: visible;
min-height: auto;
width: 100%;
display: block;
flex: 1;
}
.docs-header {
padding: 16px;
}
}
</style>