From e93b9f3a8ff55b6e4e2a0da43fb92bc9e1f74de4 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 13 Jan 2026 20:14:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + backend/routes/pages.js | 130 +++++++++++- frontend/src/components/docs/DocsContent.vue | 157 +++++++++++++- frontend/src/router/index.js | 5 + frontend/src/services/pagesService.js | 11 + .../src/views/content/PublishedPageView.vue | 192 ++++++++++++++++++ 6 files changed, 484 insertions(+), 17 deletions(-) create mode 100644 frontend/src/views/content/PublishedPageView.vue diff --git a/.gitignore b/.gitignore index 79cdbd1..ee39a10 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/routes/pages.js b/backend/routes/pages.js index 562c304..22070d7 100644 --- a/backend/routes/pages.js +++ b/backend/routes/pages.js @@ -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}`; - - sitemap += ` + 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 += ` ${escapeXml(pageUrl)} ${lastmod.split('T')[0]} weekly - 0.6 + 0.7 `; } diff --git a/frontend/src/components/docs/DocsContent.vue b/frontend/src/components/docs/DocsContent.vue index 79e6c5f..421e768 100644 --- a/frontend/src/components/docs/DocsContent.vue +++ b/frontend/src/components/docs/DocsContent.vue @@ -139,6 +139,26 @@ + + +
@@ -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; + } +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e99470a..9c98f51 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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', diff --git a/frontend/src/services/pagesService.js b/frontend/src/services/pagesService.js index e1ee196..b7c22bd 100644 --- a/frontend/src/services/pagesService.js +++ b/frontend/src/services/pagesService.js @@ -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; diff --git a/frontend/src/views/content/PublishedPageView.vue b/frontend/src/views/content/PublishedPageView.vue new file mode 100644 index 0000000..30059cc --- /dev/null +++ b/frontend/src/views/content/PublishedPageView.vue @@ -0,0 +1,192 @@ + + + + + + + +