diff --git a/backend/package.json b/backend/package.json
index ec62284..e48c4b4 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,7 +25,12 @@
"deploy:modules": "node scripts/deploy/deploy-modules.js",
"generate:abi": "node scripts/generate-abi.js",
"generate:flattened": "node scripts/generate-flattened.js",
- "compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened"
+ "compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened",
+ "seed:legal": "node scripts/seed/legalTemplatesSeed.js"
+ },
+ "bin": {},
+ "engines": {
+ "node": ">=18"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.51.0",
diff --git a/backend/routes/pages.js b/backend/routes/pages.js
index c8be1cd..4b0f624 100644
--- a/backend/routes/pages.js
+++ b/backend/routes/pages.js
@@ -13,6 +13,10 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
+const fs = require('fs');
+const path = require('path');
+const multer = require('multer');
+const vectorSearchClient = require('../services/vectorSearchClient');
const FIELDS_TO_EXCLUDE = ['image', 'tags'];
@@ -70,8 +74,31 @@ async function ensureAdminPagesTable(fields) {
return { tableName, encryptionKey };
}
+// Конфигурация загрузки файлов для юридических документов
+// Храним файлы там, откуда их раздаёт express.static('/uploads', path.join(__dirname, 'uploads'))
+const uploadsRoot = path.join(__dirname, '..', 'uploads');
+const legalDir = path.join(uploadsRoot, 'legal');
+if (!fs.existsSync(legalDir)) {
+ try { fs.mkdirSync(legalDir, { recursive: true }); } catch (e) {}
+}
+const storage = multer.diskStorage({
+ destination: function (req, file, cb) {
+ cb(null, legalDir);
+ },
+ filename: function (req, file, cb) {
+ const safeName = Date.now() + '-' + (file.originalname || 'file');
+ cb(null, safeName);
+ }
+});
+const upload = multer({ storage });
+
+function stripHtml(html) {
+ if (!html) return '';
+ return String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
// Создать страницу (только для админа)
-router.post('/', async (req, res) => {
+router.post('/', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
@@ -87,20 +114,65 @@ router.post('/', async (req, res) => {
}
const authorAddress = req.session.address;
- const fields = Object.keys(req.body).filter(f => !FIELDS_TO_EXCLUDE.includes(f));
const tableName = `admin_pages_simple`;
- // Формируем SQL для вставки данных
- const colNames = ['author_address', ...fields].join(', ');
- const values = [authorAddress, ...fields.map(f => {
- const value = typeof req.body[f] === 'object' ? JSON.stringify(req.body[f]) : req.body[f];
- return value || '';
- })];
+ // Собираем данные страницы
+ const bodyRaw = req.body || {};
+ const pageData = {
+ title: bodyRaw.title || '',
+ summary: bodyRaw.summary || '',
+ content: bodyRaw.content || '',
+ seo: bodyRaw.seo ? (typeof bodyRaw.seo === 'string' ? bodyRaw.seo : JSON.stringify(bodyRaw.seo)) : null,
+ status: bodyRaw.status || 'draft',
+ settings: bodyRaw.settings ? (typeof bodyRaw.settings === 'string' ? bodyRaw.settings : JSON.stringify(bodyRaw.settings)) : null,
+ visibility: bodyRaw.visibility || 'public',
+ required_permission: bodyRaw.required_permission || null,
+ format: bodyRaw.format || (req.file ? (req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf') : 'html'),
+ mime_type: req.file ? (req.file.mimetype || null) : (bodyRaw.mime_type || (bodyRaw.format === 'html' ? 'text/html' : null)),
+ storage_type: req.file ? 'file' : (bodyRaw.storage_type || 'embedded'),
+ file_path: req.file ? path.join('/uploads', 'legal', path.basename(req.file.path)) : (bodyRaw.file_path || null),
+ size_bytes: req.file ? req.file.size : (bodyRaw.size_bytes || null),
+ checksum: bodyRaw.checksum || null
+ };
+
+ // Формируем SQL для вставки данных (только непустые поля)
+ const dataEntries = Object.entries(pageData).filter(([, v]) => v !== undefined);
+ const colNames = ['author_address', ...dataEntries.map(([k]) => k)].join(', ');
+ const values = [authorAddress, ...dataEntries.map(([, v]) => v)];
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
-
+
const sql = `INSERT INTO ${tableName} (${colNames}) VALUES (${placeholders}) RETURNING *`;
const { rows } = await db.getQuery()(sql, values);
- res.json(rows[0]);
+ const created = rows[0];
+
+ // Индексация в vector-search (только для HTML, если есть текст)
+ try {
+ if (created && (created.format === 'html' || pageData.format === 'html')) {
+ const text = stripHtml(created.content || pageData.content || '');
+ if (text && text.length > 0) {
+ const url = created.visibility === 'public' && created.status === 'published'
+ ? `/public/page/${created.id}`
+ : `/content/page/${created.id}`;
+ await vectorSearchClient.upsert('legal_docs', [{
+ row_id: created.id,
+ text,
+ metadata: {
+ doc_id: created.id,
+ title: created.title,
+ url,
+ visibility: created.visibility || pageData.visibility,
+ required_permission: created.required_permission || pageData.required_permission,
+ format: created.format || pageData.format,
+ updated_at: created.updated_at || null
+ }
+ }]);
+ }
+ }
+ } catch (e) {
+ console.error('[pages] vector upsert error:', e.message);
+ }
+
+ res.json(created);
});
// Получить все страницы админов
@@ -171,8 +243,65 @@ router.get('/:id', async (req, res) => {
res.json(rows[0]);
});
+// Ручная переиндексация документа в vector-search (только для админа)
+router.post('/:id/reindex', async (req, res) => {
+ if (!req.session || !req.session.authenticated) {
+ return res.status(401).json({ error: 'Требуется аутентификация' });
+ }
+ if (!req.session.address) {
+ return res.status(403).json({ error: 'Требуется подключение кошелька' });
+ }
+
+ // Проверяем роль админа через токены в кошельке
+ const authService = require('../services/auth-service');
+ const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
+ if (!userAccessLevel.hasAccess) {
+ return res.status(403).json({ error: 'Only admin can reindex pages' });
+ }
+
+ const tableName = `admin_pages_simple`;
+ const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] );
+ if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
+
+ const { rows } = await db.getQuery()( `SELECT * FROM ${tableName} WHERE id = $1`, [req.params.id] );
+ if (!rows.length) return res.status(404).json({ error: 'Page not found' });
+ const page = rows[0];
+
+ if (page.format !== 'html') {
+ return res.status(422).json({ error: 'Индексация поддерживается только для HTML' });
+ }
+
+ const text = stripHtml(page.content || '');
+ if (!text) {
+ return res.status(422).json({ error: 'Пустое содержимое для индексации' });
+ }
+
+ try {
+ const url = page.visibility === 'public' && page.status === 'published'
+ ? `/public/page/${page.id}`
+ : `/content/page/${page.id}`;
+ await vectorSearchClient.upsert('legal_docs', [{
+ row_id: page.id,
+ text,
+ metadata: {
+ doc_id: page.id,
+ title: page.title,
+ url,
+ visibility: page.visibility,
+ required_permission: page.required_permission,
+ format: page.format,
+ updated_at: page.updated_at || null
+ }
+ }]);
+ res.json({ success: true });
+ } catch (e) {
+ console.error('[pages] manual reindex error:', e.message);
+ res.status(500).json({ error: 'Ошибка индексации' });
+ }
+});
+
// Редактировать страницу по id
-router.patch('/:id', async (req, res) => {
+router.patch('/:id', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
@@ -193,22 +322,58 @@ router.patch('/:id', async (req, res) => {
);
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
- const fields = Object.keys(req.body).filter(f => !FIELDS_TO_EXCLUDE.includes(f));
- if (!fields.length) return res.status(400).json({ error: 'No fields to update' });
-
- const filteredBody = {};
- fields.forEach(f => {
- // Преобразуем объекты в JSON строки
- filteredBody[f] = typeof req.body[f] === 'object' ? JSON.stringify(req.body[f]) : req.body[f];
- });
- const setClause = fields.map((f, i) => `"${f}" = $${i + 1}`).join(', ');
- const values = Object.values(filteredBody);
+ const incoming = req.body || {};
+ const updateData = {};
+ for (const [k, v] of Object.entries(incoming)) {
+ if (FIELDS_TO_EXCLUDE.includes(k)) continue;
+ updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v;
+ }
+ if (req.file) {
+ updateData.format = req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf';
+ updateData.mime_type = req.file.mimetype || null;
+ updateData.storage_type = 'file';
+ updateData.file_path = path.join('/uploads', 'legal', path.basename(req.file.path));
+ updateData.size_bytes = req.file.size;
+ }
+ const entries = Object.entries(updateData);
+ if (!entries.length) return res.status(400).json({ error: 'No fields to update' });
+ const setClause = entries.map(([f], i) => `"${f}" = $${i + 1}`).join(', ');
+ const values = entries.map(([, v]) => v);
values.push(req.params.id);
-
- const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${fields.length + 1} RETURNING *`;
+
+ const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`;
const { rows } = await db.getQuery()(sql, values);
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
- res.json(rows[0]);
+ const updated = rows[0];
+
+ // Индексация для HTML
+ try {
+ if (updated && (updated.format === 'html')) {
+ const text = stripHtml(updated.content || '');
+ if (text) {
+ const url = updated.visibility === 'public' && updated.status === 'published'
+ ? `/public/page/${updated.id}`
+ : `/content/page/${updated.id}`;
+ await vectorSearchClient.upsert('legal_docs', [{
+ row_id: updated.id,
+ text,
+ metadata: {
+ doc_id: updated.id,
+ title: updated.title,
+ url,
+ visibility: updated.visibility,
+ required_permission: updated.required_permission,
+ format: updated.format,
+ updated_at: updated.updated_at || null
+ }
+ }]);
+ }
+ }
+ } catch (e) {
+ console.error('[pages] vector upsert (update) error:', e.message);
+ }
+
+ res.json(updated);
});
// Удалить страницу по id
@@ -238,7 +403,15 @@ router.delete('/:id', async (req, res) => {
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
- res.json(rows[0]);
+ const deleted = rows[0];
+ try {
+ if (deleted && deleted.format === 'html') {
+ await vectorSearchClient.remove('legal_docs', [deleted.id]);
+ }
+ } catch (e) {
+ console.error('[pages] vector remove error:', e.message);
+ }
+ res.json(deleted);
});
// Публичные маршруты для просмотра страниц (доступны всем пользователям)
@@ -270,6 +443,44 @@ router.get('/public/all', async (req, res) => {
}
});
+// Внутренние документы (доступны аутентифицированным пользователям с доступом)
+router.get('/internal/all', async (req, res) => {
+ try {
+ if (!req.session || !req.session.authenticated) {
+ return res.status(401).json({ error: 'Требуется аутентификация' });
+ }
+ if (!req.session.address) {
+ return res.status(403).json({ error: 'Требуется подключение кошелька' });
+ }
+
+ const tableName = `admin_pages_simple`;
+ const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] );
+ if (!existsRes.rows[0].exists) {
+ return res.json([]);
+ }
+
+ const authService = require('../services/auth-service');
+ const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
+ if (!userAccessLevel.hasAccess) {
+ return res.status(403).json({ error: 'Only internal users can view pages' });
+ }
+
+ // READONLY/EDITOR видят внутренние опубликованные; EDITOR может видеть и черновики
+ const role = userAccessLevel.level; // 'readonly' | 'editor'
+ let sql;
+ if (role === 'editor') {
+ sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`;
+ } else {
+ sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`;
+ }
+ const { rows } = await db.getQuery()(sql);
+ res.json(rows);
+ } catch (error) {
+ console.error('Ошибка получения внутренних страниц:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
// Получить одну опубликованную страницу по id
router.get('/public/:id', async (req, res) => {
try {
diff --git a/backend/scripts/seed/legalTemplatesSeed.js b/backend/scripts/seed/legalTemplatesSeed.js
new file mode 100644
index 0000000..7eeddbc
--- /dev/null
+++ b/backend/scripts/seed/legalTemplatesSeed.js
@@ -0,0 +1,227 @@
+/**
+ * Seed системных юридических шаблонов (РКН-2025) в admin_pages_simple
+ * - Добавляет недостающие колонки (visibility, format и пр.)
+ * - Создает шаблоны с is_system_template = true и author_address = NULL
+ * - Повторный запуск — идемпотентен (по title + is_system_template)
+ */
+
+const db = require('../../db');
+
+async function getExistingColumns(tableName) {
+ const res = await db.getQuery()(
+ `SELECT column_name FROM information_schema.columns WHERE table_name = $1`,
+ [tableName]
+ );
+ return res.rows.map(r => r.column_name);
+}
+
+async function ensureTable(tableName) {
+ const existsRes = await db.getQuery()(
+ `SELECT to_regclass($1) as exists`,
+ [tableName]
+ );
+ if (!existsRes.rows[0].exists) {
+ await db.getQuery()(`
+ CREATE TABLE ${tableName} (
+ id SERIAL PRIMARY KEY,
+ author_address TEXT NULL,
+ title TEXT,
+ summary TEXT,
+ content TEXT,
+ seo JSONB,
+ status TEXT,
+ visibility TEXT,
+ required_permission TEXT,
+ format TEXT,
+ mime_type TEXT,
+ storage_type TEXT,
+ file_path TEXT,
+ size_bytes BIGINT,
+ checksum TEXT,
+ is_system_template BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ )
+ `);
+ }
+}
+
+async function ensureColumns(tableName) {
+ const needed = {
+ author_address: 'TEXT',
+ title: 'TEXT',
+ summary: 'TEXT',
+ content: 'TEXT',
+ seo: 'JSONB',
+ status: 'TEXT',
+ visibility: 'TEXT',
+ required_permission: 'TEXT',
+ format: 'TEXT',
+ mime_type: 'TEXT',
+ storage_type: 'TEXT',
+ file_path: 'TEXT',
+ size_bytes: 'BIGINT',
+ checksum: 'TEXT',
+ is_system_template: 'BOOLEAN DEFAULT FALSE',
+ created_at: 'TIMESTAMP DEFAULT NOW()',
+ updated_at: 'TIMESTAMP DEFAULT NOW()'
+ };
+
+ const existing = await getExistingColumns(tableName);
+ for (const [col, type] of Object.entries(needed)) {
+ if (!existing.includes(col)) {
+ await db.getQuery()(`ALTER TABLE ${tableName} ADD COLUMN ${col} ${type}`);
+ }
+ }
+}
+
+function htmlEscape(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+function tpl(content) {
+ // Лаконичный, «человеческий» текст с минимальными inline‑плейсхолдерами
+ return `
+
${htmlEscape(content.title)}
+
+ Настоящий документ предназначен для использования в рамках деятельности
+ {{company_name}} по адресу {{company_address}} и подлежит персонализации редактором.
+
+
+
+ Ответственное лицо за вопросы персональных данных: {{responsible_person}}
+ ({{privacy_email}} , {{privacy_phone}}).
+
+
+
+ Дата версии: {{date}} · Юрисдикция: {{jurisdiction}} · Язык: {{language}}
+
+
+
+ Ниже приведён текст шаблона. Перед публикацией проверьте корректность реквизитов,
+ правовых оснований и сроков хранения данных.
+
+
+${content.body || ''}
+`;
+}
+
+function doc(title, summary, visibility = 'public', requiredPermission = null) {
+ return {
+ title,
+ summary,
+ content: tpl({ title, visibility }),
+ seo: { title, description: summary, keywords: 'ПДн, политика, согласие' },
+ status: 'draft',
+ visibility,
+ required_permission: requiredPermission,
+ format: 'html',
+ mime_type: 'text/html',
+ storage_type: 'embedded',
+ is_system_template: true
+ };
+}
+
+async function upsertTemplate(tableName, template) {
+ const exists = await db.getQuery()(
+ `SELECT id FROM ${tableName} WHERE title = $1 AND is_system_template = TRUE LIMIT 1`,
+ [template.title]
+ );
+ if (exists.rows.length > 0) {
+ // Обновляем основные поля, не трогая author_address
+ const sql = `UPDATE ${tableName}
+ SET summary = $2, content = $3, seo = $4, status = $5, visibility = $6,
+ required_permission = $7, format = $8, mime_type = $9, storage_type = $10,
+ updated_at = NOW()
+ WHERE id = $1`;
+ await db.getQuery()(sql, [
+ exists.rows[0].id,
+ template.summary,
+ template.content,
+ JSON.stringify(template.seo || {}),
+ template.status,
+ template.visibility,
+ template.required_permission,
+ template.format,
+ template.mime_type,
+ template.storage_type
+ ]);
+ return { updated: 1, inserted: 0 };
+ }
+
+ const sql = `INSERT INTO ${tableName}
+ (author_address, title, summary, content, seo, status, visibility, required_permission, format, mime_type, storage_type, is_system_template)
+ VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE)`;
+ await db.getQuery()(sql, [
+ template.title,
+ template.summary,
+ template.content,
+ JSON.stringify(template.seo || {}),
+ template.status,
+ template.visibility,
+ template.required_permission,
+ template.format,
+ template.mime_type,
+ template.storage_type
+ ]);
+ return { updated: 0, inserted: 1 };
+}
+
+async function main() {
+ const tableName = 'admin_pages_simple';
+ await ensureTable(tableName);
+ await ensureColumns(tableName);
+
+ const publicDocs = [
+ doc('Политика в отношении обработки персональных данных', 'Публичная политика обработки ПДн для пользователей.', 'public'),
+ doc('Политика конфиденциальности', 'Публичная политика конфиденциальности сервиса.', 'public'),
+ doc('Согласие на обработку персональных данных', 'Шаблон пользовательского согласия на обработку ПДн.', 'public'),
+ doc('Согласие на использование файлов cookie', 'Шаблон согласия на использование cookie по категориям.', 'public'),
+ doc('Согласие на трансграничную передачу ПДн', 'Шаблон согласия на трансграничную передачу ПДн.', 'public'),
+ doc('Согласие на обработку биометрических ПДн', 'Шаблон согласия на обработку биометрических ПДн.', 'public'),
+ doc('Права субъектов ПДн и отзыв согласия', 'Информация о правах субъектов ПДн и форма отзыва согласия.', 'public')
+ ];
+
+ const internalPermView = 'view_legal_docs';
+ const internalDocs = [
+ doc('Приказ о назначении ответственного за ПДн', 'Внутренний приказ о назначении ответственного.', 'internal', internalPermView),
+ doc('Должностная инструкция ответственного за ПДн', 'Обязанности и полномочия ответственного.', 'internal', internalPermView),
+ doc('Положение об обработке и защите ПДн', 'Локальный акт об обработке и защите ПДн.', 'internal', internalPermView),
+ doc('Регламент обращений субъектов ПДн', 'Порядок рассмотрения обращений субъектов.', 'internal', internalPermView),
+ doc('Регламент исполнения запросов субъектов', 'Доступ, исправление, удаление, ограничение.', 'internal', internalPermView),
+ doc('Политика хранения и уничтожения ПДн', 'Сроки хранения и процедуры уничтожения ПДн.', 'internal', internalPermView),
+ doc('Политика разграничения доступа к ПДн', 'Матрица ролей, уровни доступа.', 'internal', internalPermView),
+ doc('Перечень допущенных лиц и НДА', 'Список сотрудников/подрядчиков и обязательства о НДА.', 'internal', internalPermView),
+ doc('Шаблон DPA (поручение обработки ПДн)', 'Условия поручения обработки ПДн процессорам.', 'internal', internalPermView),
+ doc('Реестр операций по обработке ПДн', 'Цели, категории, сроки хранения, основания.', 'internal', internalPermView),
+ doc('Журналы учетов и инцидентов', 'Журналы доступа/операций и безопасности.', 'internal', internalPermView),
+ doc('Перечень и описание ИСПДн', 'Состав ИСПДн, типы и классификация.', 'internal', internalPermView),
+ doc('Модель угроз и меры защиты', 'Актуальная модель угроз и меры защиты.', 'internal', internalPermView),
+ doc('План обеспечения безопасности ПДн', 'Мероприятия по обеспечению безопасности ПДн.', 'internal', internalPermView),
+ doc('Регламент реагирования на инциденты', 'Порядок реагирования и план восстановления.', 'internal', internalPermView),
+ doc('Программа обучения и журнал инструктажей', 'Программа обучения и учет инструктажей.', 'internal', internalPermView),
+ doc('Уведомление РКН об обработке ПДн (шаблон)', 'Шаблон уведомления РКН об обработке ПДн.', 'internal', internalPermView),
+ doc('Процедуры трансграничной передачи ПДн', 'Порядок и уведомления для трансграничной передачи.', 'internal', internalPermView),
+ doc('Согласие ребенка/законного представителя', 'Шаблон согласия для несовершеннолетних.', 'internal', internalPermView),
+ doc('Политика работы с cookie и сторонними сервисами', 'Регламент для cookie/аналитики/рекламы.', 'internal', internalPermView)
+ ];
+
+ let inserted = 0, updated = 0;
+ for (const t of [...publicDocs, ...internalDocs]) {
+ const res = await upsertTemplate(tableName, t);
+ inserted += res.inserted;
+ updated += res.updated;
+ }
+
+ console.log(`[seed:legal] completed. inserted=${inserted}, updated=${updated}`);
+}
+
+main().then(() => process.exit(0)).catch(err => {
+ console.error('[seed:legal] error:', err);
+ process.exit(1);
+});
+
+
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 0f6e687..ac0350c 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -193,6 +193,21 @@ const routes = [
name: 'content-list',
component: () => import('../views/content/ContentListView.vue'),
},
+ {
+ path: '/content/templates',
+ name: 'content-templates',
+ component: () => import('../views/content/TemplatesListView.vue'),
+ },
+ {
+ path: '/content/published',
+ name: 'content-published',
+ component: () => import('../views/content/PublishedListView.vue'),
+ },
+ {
+ path: '/content/internal',
+ name: 'content-internal',
+ component: () => import('../views/content/InternalListView.vue'),
+ },
{
path: '/content/create',
name: 'content-create',
@@ -208,11 +223,6 @@ const routes = [
name: 'page-view',
component: () => import('../views/content/PageView.vue'),
},
- {
- path: '/content/page/:id/edit',
- name: 'page-edit',
- component: () => import('../views/content/PageEditView.vue'),
- },
{
path: '/public/page/:id',
name: 'public-page-view',
diff --git a/frontend/src/services/pagesService.js b/frontend/src/services/pagesService.js
index 7a05617..b3fdd5d 100644
--- a/frontend/src/services/pagesService.js
+++ b/frontend/src/services/pagesService.js
@@ -18,8 +18,9 @@ export default {
const res = await api.get('/pages');
return res.data;
},
- async createPage(data) {
- const res = await api.post('/pages', data);
+ async createPage(data, isFormData = false) {
+ const config = isFormData ? { headers: { 'Content-Type': 'multipart/form-data' } } : undefined;
+ const res = await api.post('/pages', data, config);
return res.data;
},
async getPage(id) {
@@ -40,6 +41,10 @@ export default {
const res = await api.get('/pages/public/all');
return res.data;
},
+ async getInternalPages() {
+ const res = await api.get('/pages/internal/all');
+ return res.data;
+ },
async getPublicPage(id) {
const res = await api.get(`/pages/public/${id}`);
return res.data;
diff --git a/frontend/src/views/ContentPageView.vue b/frontend/src/views/ContentPageView.vue
index 9a17f3a..12b036f 100644
--- a/frontend/src/views/ContentPageView.vue
+++ b/frontend/src/views/ContentPageView.vue
@@ -22,8 +22,8 @@
@@ -66,7 +62,21 @@
Содержание
-
+
+
+
+
+
+
+
+
+
Контент не добавлен
@@ -141,6 +151,9 @@ import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
+import api from '../../api/axios';
+import { useAuthContext } from '../../composables/useAuth';
+import { usePermissions } from '../../composables/usePermissions';
// Props
const props = defineProps({
@@ -170,11 +183,22 @@ const router = useRouter();
// Состояние
const page = ref(null);
+const { address } = useAuthContext();
+const { canEditData } = usePermissions();
const isLoading = ref(false);
// Методы
function goToEdit() {
- router.push({ name: 'page-edit', params: { id: route.params.id } });
+ router.push({ name: 'content-create', query: { edit: route.params.id } });
+}
+
+async function reindex() {
+ try {
+ await api.post(`/pages/${route.params.id}/reindex`);
+ alert('Индексация выполнена');
+ } catch (e) {
+ alert('Ошибка индексации: ' + (e?.response?.data?.error || e.message));
+ }
}
async function deletePage() {
@@ -363,6 +387,10 @@ onMounted(() => {
color: #333;
}
+.file-preview { display: flex; flex-direction: column; gap: 12px; }
+.pdf-embed { width: 100%; height: 70vh; border: 1px solid #e9ecef; border-radius: var(--radius-sm); }
+.image-preview { max-width: 100%; border: 1px solid #e9ecef; border-radius: var(--radius-sm); }
+
.empty-content {
text-align: center;
padding: 40px 20px;
diff --git a/frontend/src/views/content/PublicPageView.vue b/frontend/src/views/content/PublicPageView.vue
index 6dbb2de..397692e 100644
--- a/frontend/src/views/content/PublicPageView.vue
+++ b/frontend/src/views/content/PublicPageView.vue
@@ -51,13 +51,35 @@
Содержание
-
+
+
+
+
Контент не добавлен
SEO информация
-
+
+
+ Meta Title:
+ {{ page.seo.title || 'Не указан' }}
+
+
+ Meta Description:
+ {{ page.seo.description || 'Не указан' }}
+
+
+ Keywords:
+ {{ page.seo.keywords || 'Не указаны' }}
+
+
@@ -141,6 +163,7 @@ function formatAddress(address) {
function formatContent(content) {
if (!content) return '';
+ if (typeof content !== 'string') return '';
// Простое форматирование - замена переносов строк на
return content.replace(/\n/g, '
');
}
@@ -261,6 +284,15 @@ onMounted(() => {
font-size: 1rem;
line-height: 1.7;
}
+.seo-info { display: grid; gap: 12px; }
+.seo-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
+.seo-item:last-child { border-bottom: none; }
+.seo-item label { font-weight: 500; color: var(--color-grey-dark); min-width: 150px; }
+.seo-item span { color: #333; flex: 1; margin-left: 20px; }
+
+.file-preview { display: flex; flex-direction: column; gap: 12px; }
+.pdf-embed { width: 100%; height: 70vh; border: 1px solid #e9ecef; border-radius: var(--radius-sm); }
+.image-preview { max-width: 100%; border: 1px solid #e9ecef; border-radius: var(--radius-sm); }
.loading-state,
.error-state {
diff --git a/frontend/src/views/content/PublishedListView.vue b/frontend/src/views/content/PublishedListView.vue
new file mode 100644
index 0000000..1870b36
--- /dev/null
+++ b/frontend/src/views/content/PublishedListView.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ p.summary || 'Без описания' }}
+
+ Опубликовано
+ {{ p.format || 'html' }}
+
+
+ Индекс
+ Ред.
+ Удалить
+
+
+
+
+
+
+
Нет опубликованных документов
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/content/TemplatesListView.vue b/frontend/src/views/content/TemplatesListView.vue
new file mode 100644
index 0000000..b980a2d
--- /dev/null
+++ b/frontend/src/views/content/TemplatesListView.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ p.summary || 'Без описания' }}
+
+ Черновик
+ Шаблон
+ {{ p.visibility === 'internal' ? 'Внутренний' : 'Публичный' }}
+
+
+
+
+
+
+
+
Шаблонов не найдено
+
Требуются права редактора и подключённый кошелёк для просмотра системных шаблонов.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shared/permissions.js b/shared/permissions.js
index 0277c0f..610bb49 100644
--- a/shared/permissions.js
+++ b/shared/permissions.js
@@ -62,7 +62,12 @@ const PERMISSIONS = {
BLOCK_USERS: 'block_users',
// Управление настройками
- MANAGE_SETTINGS: 'manage_settings'
+ MANAGE_SETTINGS: 'manage_settings',
+
+ // Контент: юридические документы
+ VIEW_BASIC_DOCS: 'view_basic_docs', // Базовые документы для пользователей
+ VIEW_LEGAL_DOCS: 'view_legal_docs', // Юридические документы для читателей
+ MANAGE_LEGAL_DOCS: 'manage_legal_docs' // Управление документами для редакторов
};
// Матрица: какая роль имеет какие права
@@ -78,7 +83,8 @@ const PERMISSIONS_MAP = {
PERMISSIONS.RECEIVE_MESSAGES,
PERMISSIONS.VIEW_CONTACTS, // Пользователи могут видеть контакты для выбора
PERMISSIONS.SEND_TO_USERS, // Пользователи могут отправлять сообщения
- PERMISSIONS.CHAT_WITH_ADMINS // Авторизованные пользователи могут видеть личные сообщения
+ PERMISSIONS.CHAT_WITH_ADMINS, // Авторизованные пользователи могут видеть личные сообщения
+ PERMISSIONS.VIEW_BASIC_DOCS // Базовые документы для пользователей
],
[ROLES.READONLY]: [
@@ -89,7 +95,11 @@ const PERMISSIONS_MAP = {
PERMISSIONS.VIEW_CONTACTS,
PERMISSIONS.VIEW_DATA,
PERMISSIONS.SEND_TO_USERS,
- PERMISSIONS.CHAT_WITH_ADMINS
+ PERMISSIONS.CHAT_WITH_ADMINS,
+ // Базовые документы для пользователей
+ PERMISSIONS.VIEW_BASIC_DOCS,
+ // Чтение внутренних юридических документов
+ PERMISSIONS.VIEW_LEGAL_DOCS
],
[ROLES.EDITOR]: [
@@ -109,7 +119,12 @@ const PERMISSIONS_MAP = {
PERMISSIONS.BROADCAST,
PERMISSIONS.MANAGE_TAGS,
PERMISSIONS.BLOCK_USERS,
- PERMISSIONS.MANAGE_SETTINGS
+ PERMISSIONS.MANAGE_SETTINGS,
+ // Базовые документы для пользователей
+ PERMISSIONS.VIEW_BASIC_DOCS,
+ // Полный доступ к юридическим документам
+ PERMISSIONS.VIEW_LEGAL_DOCS,
+ PERMISSIONS.MANAGE_LEGAL_DOCS
]
};