feat: новая функция

This commit is contained in:
2025-10-23 13:53:44 +03:00
parent b2e0795e8a
commit 918da882d2
15 changed files with 1327 additions and 827 deletions

View File

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

View File

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

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function tpl(content) {
// Лаконичный, «человеческий» текст с минимальными inlineплейсхолдерами
return `
<h1>${htmlEscape(content.title)}</h1>
<p>
Настоящий документ предназначен для использования в рамках деятельности
{{company_name}} по адресу {{company_address}} и подлежит персонализации редактором.
</p>
<p>
Ответственное лицо за вопросы персональных данных: {{responsible_person}}
(<a href="mailto:{{privacy_email}}">{{privacy_email}}</a>, {{privacy_phone}}).
</p>
<p>
Дата версии: {{date}} · Юрисдикция: {{jurisdiction}} · Язык: {{language}}
</p>
<p>
Ниже приведён текст шаблона. Перед публикацией проверьте корректность реквизитов,
правовых оснований и сроков хранения данных.
</p>
${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);
});

View File

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

View File

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

View File

@@ -22,8 +22,8 @@
<!-- Заголовок страницы -->
<div class="page-header">
<div class="header-content">
<h1>📝 Создание страницы</h1>
<p>Создайте новую страницу для вашего DLE</p>
<h1>{{ isEditMode ? 'Редактирование страницы' : 'Создание страницы' }}</h1>
<p>{{ isEditMode ? 'Редактируйте существующую страницу' : 'Создайте новую страницу для вашего DLE' }}</p>
</div>
<div class="header-actions">
<button class="close-btn" @click="goBack">×</button>
@@ -33,6 +33,41 @@
<!-- Основной контент с тенью -->
<div class="content-block">
<form class="content-form" @submit.prevent="handleSubmit">
<!-- Параметры документа -->
<div class="form-section">
<h2>Параметры документа</h2>
<div class="form-group">
<label for="visibility">Видимость</label>
<select v-model="form.visibility" id="visibility" class="form-select">
<option value="public">Публичный</option>
<option value="internal">Внутренний</option>
</select>
</div>
<div class="form-group" v-if="form.visibility === 'internal'">
<label for="required-permission">Уровень доступа к документу</label>
<select
v-model="form.requiredPermission"
id="required-permission"
class="form-select"
>
<option value=""> Выберите роль </option>
<option :value="PERMISSIONS.VIEW_BASIC_DOCS">Пользователь</option>
<option :value="PERMISSIONS.VIEW_LEGAL_DOCS">Читатель</option>
<option :value="PERMISSIONS.MANAGE_LEGAL_DOCS">Редактор</option>
</select>
</div>
<div class="form-group">
<label for="format">Формат</label>
<select v-model="form.format" id="format" class="form-select">
<option value="html">HTML (встроенный)</option>
<option value="pdf" disabled>PDF (загрузка файла) скоро</option>
<option value="image" disabled>Изображение (PNG/JPG) скоро</option>
</select>
</div>
<p class="form-hint">
Для HTML-постов переменные подставляются при рендере. Реквизиты заполняются на странице настроек контента.
</p>
</div>
<!-- Основная информация -->
<div class="form-section">
<h2>Основная информация</h2>
@@ -63,7 +98,7 @@
<!-- Контент -->
<div class="form-section">
<h2>Содержание</h2>
<div class="form-group">
<div class="form-group" v-if="form.format === 'html'">
<label for="content">Основной контент *</label>
<textarea
v-model="form.content"
@@ -78,6 +113,11 @@
<span>Символов: {{ characterCount }}</span>
</div>
</div>
<div class="form-group" v-else>
<label for="file">Файл (PDF/PNG/JPG) *</label>
<input id="file" type="file" accept="application/pdf,image/png,image/jpeg" @change="onFileChange" class="form-input" />
<p class="form-hint" v-if="fileName">Выбран файл: {{ fileName }}</p>
</div>
</div>
<!-- SEO настройки -->
@@ -115,28 +155,6 @@
</div>
</div>
<!-- Настройки публикации -->
<div class="form-section">
<h2>Настройки публикации</h2>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
v-model="form.settings.autoPublish"
class="form-checkbox"
/>
<span>Опубликовать сразу после создания</span>
</label>
</div>
<div class="form-group">
<label for="status">Статус</label>
<select v-model="form.status" id="status" class="form-select">
<option value="draft">Черновик</option>
<option value="published">Опубликовано</option>
<option value="pending">На модерации</option>
</select>
</div>
</div>
<!-- Кнопки действий -->
<div class="form-actions">
@@ -145,8 +163,8 @@
Отмена
</button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<i class="fas fa-save"></i>
{{ isSubmitting ? 'Сохранение...' : 'Создать страницу' }}
<i class="fas fa-globe"></i>
{{ isSubmitting ? 'Публикация...' : 'Опубликовать' }}
</button>
</div>
</form>
@@ -156,10 +174,13 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import pagesService from '../services/pagesService';
import { PERMISSIONS } from '/app/shared/permissions.js';
import { useAuthContext } from '../composables/useAuth';
import { usePermissions } from '../composables/usePermissions';
// Props
const props = defineProps({
@@ -185,6 +206,17 @@ const props = defineProps({
const emit = defineEmits(['auth-action-completed']);
const router = useRouter();
const route = useRoute();
const PERMISSIONS_REF = PERMISSIONS; // для шаблона
// Проверка прав доступа
const { address } = useAuthContext();
const { hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS));
// Режим редактирования
const isEditMode = computed(() => !!route.query.edit);
const editId = computed(() => route.query.edit);
// Состояние формы
const form = ref({
@@ -199,10 +231,15 @@ const form = ref({
settings: {
autoPublish: false
},
status: 'draft'
status: 'published',
visibility: 'public',
requiredPermission: '',
format: 'html'
});
const isSubmitting = ref(false);
const fileBlob = ref(null);
const fileName = ref('');
// Вычисляемые свойства
const wordCount = computed(() => {
@@ -218,6 +255,41 @@ function goBack() {
router.push({ name: 'content-list' });
}
function onFileChange(e) {
const f = e.target.files && e.target.files[0];
if (f) {
fileBlob.value = f;
fileName.value = f.name;
} else {
fileBlob.value = null;
fileName.value = '';
}
}
// Загрузка данных для редактирования
async function loadPageForEdit() {
if (!isEditMode.value || !editId.value) return;
try {
const page = await pagesService.getPage(editId.value);
if (page) {
form.value.title = page.title || '';
form.value.summary = page.summary || '';
form.value.content = page.content || '';
form.value.seo.title = page.seo?.title || '';
form.value.seo.description = page.seo?.description || '';
form.value.seo.keywords = page.seo?.keywords || '';
form.value.status = page.status || 'draft';
form.value.visibility = page.visibility || 'public';
form.value.requiredPermission = page.required_permission || '';
form.value.format = page.format || 'html';
}
} catch (error) {
console.error('Ошибка загрузки страницы для редактирования:', error);
alert('Ошибка загрузки данных страницы');
}
}
async function handleSubmit() {
if (!form.value.title.trim()) {
alert('Заполните заголовок страницы!');
@@ -229,27 +301,98 @@ async function handleSubmit() {
return;
}
if (form.value.format === 'html') {
if (!form.value.content.trim()) {
alert('Заполните контент страницы!');
return;
}
} else {
if (!fileBlob.value) {
alert('Загрузите файл документа!');
return;
}
}
try {
isSubmitting.value = true;
let page;
if (isEditMode.value) {
// Режим редактирования
if (form.value.format === 'html') {
const pageData = {
title: form.value.title.trim(),
summary: form.value.summary.trim(),
content: form.value.content.trim(),
seo: form.value.seo,
status: form.value.status,
settings: form.value.settings
settings: form.value.settings,
visibility: form.value.visibility,
required_permission: form.value.visibility === 'internal' && form.value.requiredPermission
? form.value.requiredPermission.trim()
: null,
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded'
};
const page = await pagesService.createPage(pageData);
page = await pagesService.updatePage(editId.value, pageData);
} else {
// Отправляем как FormData для редактирования
const fd = new FormData();
fd.append('title', form.value.title.trim());
fd.append('summary', form.value.summary.trim());
fd.append('seo', JSON.stringify(form.value.seo));
fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility);
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim());
}
fd.append('format', form.value.format);
if (fileBlob.value) {
fd.append('file', fileBlob.value);
}
page = await pagesService.updatePage(editId.value, fd, true);
}
} else {
// Режим создания
if (form.value.format === 'html') {
const pageData = {
title: form.value.title.trim(),
summary: form.value.summary.trim(),
content: form.value.content.trim(),
seo: form.value.seo,
status: form.value.status,
settings: form.value.settings,
visibility: form.value.visibility,
required_permission: form.value.visibility === 'internal' && form.value.requiredPermission
? form.value.requiredPermission.trim()
: null,
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded'
};
page = await pagesService.createPage(pageData);
} else {
// Отправляем как FormData
const fd = new FormData();
fd.append('title', form.value.title.trim());
fd.append('summary', form.value.summary.trim());
fd.append('seo', JSON.stringify(form.value.seo));
fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility);
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim());
}
fd.append('format', form.value.format);
fd.append('file', fileBlob.value);
page = await pagesService.createPage(fd, true);
}
}
if (!page || !page.id) {
throw new Error('Страница не была создана');
throw new Error(isEditMode.value ? 'Страница не была обновлена' : 'Страница не была создана');
}
// Перенаправляем на список страниц
@@ -261,6 +404,19 @@ async function handleSubmit() {
isSubmitting.value = false;
}
}
// Загрузка данных при монтировании
onMounted(() => {
// Проверяем права доступа
if (!canManageLegalDocs.value || !address.value) {
router.push({ name: 'content-list' });
return;
}
if (isEditMode.value) {
loadPageForEdit();
}
});
</script>
<style scoped>

View File

@@ -25,10 +25,6 @@
<h1>Управление контентом</h1>
<p v-if="canEditData && address">Создавайте и управляйте страницами вашего DLE</p>
<p v-else>Просмотр опубликованных страниц DLE</p>
<button v-if="canEditData && address" class="btn btn-primary" @click="goToCreate">
<i class="fas fa-plus"></i>
Создать страницу
</button>
</div>
<div class="header-actions">
<button class="close-btn" @click="goBack">×</button>
@@ -37,153 +33,57 @@
<!-- Основной контент с тенью -->
<div class="content-block">
<!-- Навигация -->
<div class="content-navigation">
<div class="nav-tabs">
<button
class="nav-tab"
:class="{ active: activeTab === 'pages' }"
@click="activeTab = 'pages'"
>
<i class="fas fa-file-alt"></i>
Страницы
</button>
<button
class="nav-tab"
:class="{ active: activeTab === 'settings' }"
@click="activeTab = 'settings'"
>
<i class="fas fa-cog"></i>
Настройки
</button>
<!-- Быстрые разделы -->
<div class="quick-sections">
<div class="management-blocks">
<div class="blocks-column">
<div class="management-block">
<h3>Создать страницу</h3>
<p>Создайте новую страницу и заполните содержимое</p>
<button class="details-btn" @click="goToCreate">Подробнее</button>
</div>
<div class="management-block">
<h3>Шаблоны</h3>
<p>Системные шаблоны документов. Персонализируйте перед публикацией</p>
<button class="details-btn" @click="goToTemplates">Подробнее</button>
</div>
</div>
<!-- Контент в зависимости от активной вкладки -->
<div class="content-section">
<!-- Вкладка Страницы -->
<div v-if="activeTab === 'pages'" class="pages-section">
<div class="section-header">
<h2 v-if="canEditData && address">Созданные страницы</h2>
<h2 v-else>Опубликованные страницы</h2>
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="Поиск страниц..."
class="search-input"
>
<i class="fas fa-search search-icon"></i>
<div class="blocks-column">
<div class="management-block">
<h3>Публичные</h3>
<p>Публичные документы, доступные пользователям</p>
<button class="details-btn" @click="goToPublished">Подробнее</button>
</div>
<div class="management-block">
<h3>Настройка</h3>
<p>Юр. реквизиты и параметры подстановки переменных</p>
<button class="details-btn" @click="goToContentSettings">Подробнее</button>
</div>
</div>
<!-- Список страниц -->
<div v-if="filteredPages.length" class="pages-grid">
<div
v-for="page in filteredPages"
:key="page.id"
class="page-card"
@click="goToPage(page.id)"
>
<div class="page-card-header">
<h3>{{ page.title }}</h3>
<div class="page-actions" v-if="canEditData && address">
<button
class="action-btn edit-btn"
@click.stop="goToEdit(page.id)"
title="Редактировать"
>
<i class="fas fa-edit"></i>
</button>
<button
class="action-btn delete-btn"
@click.stop="deletePage(page.id)"
title="Удалить"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="page-card-content">
<p class="page-summary">{{ page.summary || 'Без описания' }}</p>
<div class="page-meta">
<span class="page-date">
<i class="fas fa-calendar"></i>
{{ formatDate(page.created_at) }}
</span>
<span class="page-status" :class="page.status">
<i class="fas fa-circle"></i>
{{ getStatusText(page.status) }}
</span>
<span class="page-author" v-if="page.author_address">
<i class="fas fa-user"></i>
{{ formatAddress(page.author_address) }}
</span>
<div class="blocks-column">
<div class="management-block">
<h3>Внутренние</h3>
<p>Внутренние документы, видимые только по ролям</p>
<button class="details-btn" @click="goToInternal">Подробнее</button>
</div>
</div>
</div>
</div>
<!-- Пустое состояние -->
<div v-else-if="!isLoading" class="empty-state">
<div class="empty-icon">
<i class="fas fa-file-alt"></i>
</div>
<h3 v-if="canEditData && address">Нет созданных страниц</h3>
<h3 v-else>Нет опубликованных страниц</h3>
<p v-if="canEditData && address">Создайте первую страницу для вашего DLE</p>
<p v-else>Публичные страницы появятся здесь после их создания администраторами</p>
<button v-if="canEditData && address" class="btn btn-primary" @click="goToCreate">
<i class="fas fa-plus"></i>
Создать страницу
</button>
</div>
<!-- Загрузка -->
<div v-else class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка страниц...</p>
</div>
</div>
<!-- Вкладка Настройки -->
<div v-if="activeTab === 'settings'" class="settings-section">
<div class="section-header">
<h2>Настройки контента</h2>
</div>
<div class="settings-grid">
<div class="setting-card">
<h3>SEO настройки</h3>
<div class="setting-item">
<label>Мета-теги по умолчанию</label>
<textarea v-model="seoSettings.defaultMeta" placeholder="Введите мета-теги..."></textarea>
</div>
</div>
<div class="setting-card">
<h3>Настройки публикации</h3>
<div class="setting-item">
<label>
<input type="checkbox" v-model="publishSettings.autoPublish">
Автоматическая публикация
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Секции списков и настроек удалены: навигация через карточки выше -->
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
import { useAuthContext } from '../../composables/useAuth';
import { usePermissions } from '../../composables/usePermissions';
@@ -214,45 +114,6 @@ const router = useRouter();
const { address } = useAuthContext();
const { canEditData } = usePermissions();
// Подписываемся на централизованные события очистки и обновления данных
onMounted(() => {
window.addEventListener('clear-application-data', () => {
console.log('[ContentListView] Clearing pages data');
// Очищаем данные при выходе из системы
pages.value = [];
});
window.addEventListener('refresh-application-data', () => {
console.log('[ContentListView] Refreshing pages data');
loadPages(); // Обновляем данные при входе в систему
});
});
// Состояние
const activeTab = ref('pages');
const pages = ref([]);
const isLoading = ref(false);
const searchQuery = ref('');
// Настройки
const seoSettings = ref({
defaultMeta: ''
});
const publishSettings = ref({
autoPublish: false
});
// Вычисляемые свойства
const filteredPages = computed(() => {
if (!searchQuery.value) return pages.value;
return pages.value.filter(page =>
page.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
page.summary?.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
// Методы
function goToCreate() {
router.push({ name: 'content-create' });
@@ -263,23 +124,26 @@ function goBack() {
router.go(-1);
}
function goToPage(id) {
if (canEditData.value && address.value) {
router.push({ name: 'page-view', params: { id } });
} else {
router.push({ name: 'public-page-view', params: { id } });
}
function goToTemplates() {
router.push({ name: 'content-templates' });
}
function goToEdit(id) {
router.push({ name: 'page-edit', params: { id } });
function goToPublished() {
router.push({ name: 'content-published' });
}
function goToInternal() {
router.push({ name: 'content-internal' });
}
function goToContentSettings() {
router.push({ name: 'content-settings' });
}
async function deletePage(id) {
if (confirm('Вы уверены, что хотите удалить эту страницу?')) {
try {
await pagesService.deletePage(id);
await loadPages();
// удаление здесь больше не используется
} catch (error) {
console.error('Ошибка удаления страницы:', error);
}
@@ -287,60 +151,7 @@ async function deletePage(id) {
}
function formatDate(date) {
if (!date) return 'Не указана';
return new Date(date).toLocaleDateString('ru-RU');
}
function formatAddress(address) {
if (!address) return '';
// Показываем сокращенный адрес: 0x1234...5678
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
function getStatusText(status) {
const statusMap = {
draft: 'Черновик',
published: 'Опубликовано',
archived: 'Архив'
};
return statusMap[status] || 'Неизвестно';
}
async function loadPages() {
try {
isLoading.value = true;
// Проверяем роль админа через кошелек
if (canEditData.value && address.value) {
try {
// Пытаемся загрузить админские страницы
const response = await pagesService.getPages();
pages.value = response;
} catch (error) {
if (error.response?.status === 403) {
// Пользователь не админ или нет токенов, загружаем публичные страницы
pages.value = await pagesService.getPublicPages();
} else {
throw error;
}
}
} else {
// Пользователь не админ или нет кошелька, загружаем публичные страницы
pages.value = await pagesService.getPublicPages();
}
} catch (error) {
console.error('Ошибка загрузки страниц:', error);
pages.value = [];
} finally {
isLoading.value = false;
}
}
// Загрузка данных
onMounted(() => {
loadPages();
});
// Удалены: загрузка и локальные списки — навигация через карточки
</script>
<style scoped>
@@ -415,6 +226,21 @@ onMounted(() => {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.quick-sections { margin-bottom: 24px; }
/* Стили блоков как в CRM */
.management-blocks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; }
.blocks-column { display: flex; flex-direction: column; gap: 1.5rem; align-items: stretch; }
.management-block { background: #fff; border-radius: 12px; padding: 2rem; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border: 1px solid #e9ecef; transition: all 0.3s ease; text-align: center; display: flex; flex-direction: column; justify-content: space-between; height: 250px; }
.management-block:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.12); transform: translateY(-2px); border-color: var(--color-primary); }
.management-block h3 { margin: 0 0 1rem 0; color: var(--color-primary); font-size: 1.5rem; font-weight: 600; }
.management-block p { margin: 0 0 1.5rem 0; color: #666; font-size: 1rem; line-height: 1.5; }
.details-btn { background: var(--color-primary); color: #fff; border: none; border-radius: 8px; padding: 0.75rem 1.5rem; cursor: pointer; font-size: 1rem; font-weight: 600; transition: all 0.2s; min-width: 120px; margin-top: auto; }
.details-btn:hover { background: var(--color-primary-dark); transform: translateY(-1px); }
@media (max-width: 1024px) { .management-blocks { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 768px) { .management-blocks { grid-template-columns: 1fr; } }
.content-section {
background: #f8f9fa;
border-radius: var(--radius-lg);

View File

@@ -12,9 +12,23 @@
<template>
<BaseLayout>
<div class="content-settings-block">
<h2>Настройки контента</h2>
<div class="empty-settings-placeholder">Здесь будут настройки для управления страницами.</div>
<div class="list-page">
<div class="page-header">
<div class="header-content">
<h1>Настройки контента</h1>
<p>Юр. реквизиты и параметры подстановки переменных</p>
</div>
</div>
<div class="content-block">
<div class="section-header">
<h2>Юр. реквизиты (переменные)</h2>
</div>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-cog"></i></div>
<h3>Скоро здесь появится настройка переменных</h3>
<p>Редактор сможет заполнить реквизиты для подстановки во все шаблоны</p>
</div>
</div>
</div>
</BaseLayout>
</template>
@@ -24,19 +38,13 @@ import BaseLayout from '../../components/BaseLayout.vue';
</script>
<style scoped>
.content-settings-block {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
}
.empty-settings-placeholder {
color: #888;
font-size: 1.1em;
margin-top: 2em;
}
.list-page { padding: 20px; width: 100%; }
.page-header { display:flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #f0f0f0; }
.header-content h1 { color: var(--color-primary); font-size: 2.2rem; margin: 0 0 8px 0; }
.header-content p { color: var(--color-grey-dark); margin: 0; }
.content-block { background: #f8f9fa; border-radius: var(--radius-lg); padding: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.section-header { display:flex; justify-content: space-between; align-items:center; margin-bottom: 20px; }
.section-header h2 { color: var(--color-primary); margin: 0; }
.empty-state { text-align:center; padding: 60px 20px; }
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
</style>

View File

@@ -0,0 +1,164 @@
<!--
Copyright (c) 2024-2025
-->
<template>
<BaseLayout :is-authenticated="isAuthenticated" :identities="identities" :token-balances="tokenBalances" :is-loading-tokens="isLoadingTokens" @auth-action-completed="$emit('auth-action-completed')">
<div class="list-page">
<div class="page-header">
<div class="header-content">
<h1>Внутренние документы</h1>
<p>Документы, доступные только пользователям с ролями</p>
</div>
<button class="close-btn" @click="goBack">×</button>
</div>
<div class="content-block">
<div class="section-header">
<h2>Список документов</h2>
<div class="filters">
<div class="filter-group" v-if="canManageLegalDocs && address">
<label for="permission-filter">Уровень доступа:</label>
<select v-model="permissionFilter" id="permission-filter" class="filter-select">
<option value="">Все уровни</option>
<option :value="PERMISSIONS.VIEW_BASIC_DOCS">Пользователи</option>
<option :value="PERMISSIONS.VIEW_LEGAL_DOCS">Читатели</option>
<option :value="PERMISSIONS.MANAGE_LEGAL_DOCS">Редакторы</option>
</select>
</div>
<div class="search-box">
<input v-model="search" type="text" placeholder="Поиск..." class="search-input" />
<i class="fas fa-search search-icon"></i>
</div>
</div>
</div>
<div v-if="filtered.length" class="pages-grid">
<div v-for="p in filtered" :key="p.id" class="page-card" @click="open(p.id)">
<div class="page-card-header">
<h3>{{ p.title }}</h3>
</div>
<div class="page-card-content">
<p class="page-summary">{{ p.summary || 'Без описания' }}</p>
<div class="page-meta">
<span class="page-status" :class="p.status === 'published' ? 'published' : 'draft'"><i class="fas fa-circle"></i>{{ p.status === 'published' ? 'Опубликовано' : 'Черновик' }}</span>
<span class="page-status"><i class="fas fa-lock"></i>Внутренний</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon"><i class="fas fa-file-alt"></i></div>
<h3>Нет внутренних документов</h3>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
import { useAuthContext } from '../../composables/useAuth';
import { usePermissions } from '../../composables/usePermissions';
import { PERMISSIONS } from '/app/shared/permissions.js';
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 search = ref('');
const permissionFilter = ref('');
const pages = ref([]);
// Проверка прав доступа
const { address } = useAuthContext();
const { hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS));
function goBack() { router.push({ name: 'content-list' }); }
function open(id) { router.push({ name: 'page-view', params: { id } }); }
const filtered = computed(() => {
return pages.value.filter(p => {
// Базовые фильтры
if (p.visibility !== 'internal' || p.status !== 'published') {
return false;
}
// Фильтр по поиску
if (search.value && !p.title?.toLowerCase().includes(search.value.toLowerCase())) {
return false;
}
// Фильтр по уровню доступа (только для редакторов)
if (permissionFilter.value && p.required_permission !== permissionFilter.value) {
return false;
}
// Фильтр по правам доступа
if (!p.required_permission) {
return false; // Документ без прав не показываем
}
// Проверяем права пользователя
if (p.required_permission === PERMISSIONS.VIEW_BASIC_DOCS) {
return hasPermission(PERMISSIONS.VIEW_BASIC_DOCS);
}
if (p.required_permission === PERMISSIONS.VIEW_LEGAL_DOCS) {
return hasPermission(PERMISSIONS.VIEW_LEGAL_DOCS);
}
if (p.required_permission === PERMISSIONS.MANAGE_LEGAL_DOCS) {
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
}
return false;
});
});
onMounted(async () => {
try {
pages.value = await pagesService.getInternalPages();
} catch (e) {
pages.value = [];
}
});
</script>
<style scoped>
.list-page { padding: 20px; width: 100%; }
.page-header { display:flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #f0f0f0; }
.header-content h1 { color: var(--color-primary); font-size: 2.2rem; margin: 0 0 8px 0; }
.header-content p { color: var(--color-grey-dark); margin: 0; }
.close-btn { background:none; border:none; font-size: 1.5rem; cursor:pointer; color:#888; }
/* Переиспользуем стили из TemplatesListView */
.content-block { background: #f8f9fa; border-radius: var(--radius-lg); padding: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.section-header { display:flex; justify-content: space-between; align-items:center; margin-bottom: 20px; }
.section-header h2 { color: var(--color-primary); margin: 0; }
.filters { display: flex; gap: 20px; align-items: center; }
.filter-group { display: flex; align-items: center; gap: 8px; }
.filter-group label { color: var(--color-grey-dark); font-weight: 500; }
.filter-select { padding: 8px 12px; border: 1px solid #e9ecef; border-radius: var(--radius-sm); background: #fff; font-size: 1rem; }
.search-box { position: relative; width: 300px; }
.search-input { width: 100%; padding: 10px 40px 10px 15px; border: 1px solid #e9ecef; border-radius: var(--radius-sm); font-size: 1rem; }
.search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); color: var(--color-grey-dark); }
.pages-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-top: 10px; }
.page-card { background: #fff; border: 1px solid #e9ecef; border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: all 0.2s; }
.page-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
.page-card-header h3 { margin: 0; color: var(--color-primary); font-size: 1.2rem; }
.page-summary { color: var(--color-grey-dark); margin: 8px 0 12px; }
.page-meta { display:flex; gap: 12px; font-size: 0.9rem; color: var(--color-grey-dark); align-items: center; flex-wrap: wrap; }
.page-status i { margin-right: 6px; }
.page-status.published i { color: #4caf50; }
.page-status.draft i { color: #ff9800; }
.empty-state { text-align:center; padding: 60px 20px; }
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
</style>

View File

@@ -1,484 +0,0 @@
<!--
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/HB3-ACCELERATOR
-->
<template>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div v-if="page" class="page-edit-page">
<!-- Заголовок страницы -->
<div class="page-header">
<div class="header-content">
<h1> Редактирование страницы</h1>
<p v-if="page">Редактируйте содержимое страницы "{{ page.title }}"</p>
<p v-else>Загрузка страницы...</p>
</div>
<div class="header-actions">
<button class="close-btn" @click="goBack">×</button>
</div>
</div>
<!-- Основной контент с тенью -->
<div class="content-block">
<form class="content-form" @submit.prevent="save">
<!-- Основная информация -->
<div class="form-section">
<h2>Основная информация</h2>
<div class="form-group">
<label for="title">Заголовок страницы *</label>
<input
v-model="page.title"
id="title"
type="text"
required
placeholder="Введите заголовок страницы"
class="form-input"
/>
</div>
<div class="form-group">
<label for="summary">Краткое описание *</label>
<textarea
v-model="page.summary"
id="summary"
required
rows="3"
placeholder="Краткое описание страницы"
class="form-textarea"
/>
</div>
</div>
<!-- Контент -->
<div class="form-section">
<h2>Содержание</h2>
<div class="form-group">
<label for="content">Основной контент *</label>
<textarea
v-model="page.content"
id="content"
required
rows="10"
placeholder="Введите основной контент страницы"
class="form-textarea"
/>
<div class="content-stats">
<span>Слов: {{ wordCount }}</span>
<span>Символов: {{ characterCount }}</span>
</div>
</div>
</div>
<!-- SEO настройки -->
<div class="form-section">
<h2>SEO настройки</h2>
<div class="form-group">
<label for="seo-title">Meta Title</label>
<input
v-model="page.seo.title"
id="seo-title"
type="text"
placeholder="SEO заголовок (если отличается от основного)"
class="form-input"
/>
</div>
<div class="form-group">
<label for="seo-description">Meta Description</label>
<textarea
v-model="page.seo.description"
id="seo-description"
rows="3"
placeholder="SEO описание для поисковых систем"
class="form-textarea"
/>
</div>
<div class="form-group">
<label for="seo-keywords">Keywords</label>
<input
v-model="page.seo.keywords"
id="seo-keywords"
type="text"
placeholder="Ключевые слова через запятую"
class="form-input"
/>
</div>
</div>
<!-- Настройки публикации -->
<div class="form-section">
<h2>Настройки публикации</h2>
<div class="form-group">
<label for="status">Статус</label>
<select v-model="page.status" id="status" class="form-select">
<option value="draft">Черновик</option>
<option value="published">Опубликовано</option>
<option value="pending">На модерации</option>
<option value="archived">Архив</option>
</select>
</div>
</div>
<!-- Кнопки действий -->
<div class="form-actions">
<button type="button" class="btn btn-outline" @click="goBack">
<i class="fas fa-times"></i>
Отмена
</button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<i class="fas fa-save"></i>
{{ isSubmitting ? 'Сохранение...' : 'Сохранить изменения' }}
</button>
</div>
</form>
</div>
</div>
<!-- Загрузка -->
<div v-else class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка страницы...</p>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
// Props
const props = defineProps({
isAuthenticated: {
type: Boolean,
default: false
},
identities: {
type: Array,
default: () => []
},
tokenBalances: {
type: Object,
default: () => ({})
},
isLoadingTokens: {
type: Boolean,
default: false
}
});
// Emits
const emit = defineEmits(['auth-action-completed']);
const route = useRoute();
const router = useRouter();
const page = ref(null);
const isSubmitting = ref(false);
// Вычисляемые свойства
const wordCount = computed(() => {
return page.value?.content ? page.value.content.split(/\s+/).length : 0;
});
const characterCount = computed(() => {
return page.value?.content ? page.value.content.length : 0;
});
// Методы
function goBack() {
router.push({ name: 'page-view', params: { id: route.params.id } });
}
async function save() {
if (!page.value.title.trim()) {
alert('Заполните заголовок страницы!');
return;
}
if (!page.value.summary.trim()) {
alert('Заполните описание страницы!');
return;
}
if (!page.value.content.trim()) {
alert('Заполните контент страницы!');
return;
}
try {
isSubmitting.value = true;
const pageData = {
title: page.value.title.trim(),
summary: page.value.summary.trim(),
content: page.value.content.trim(),
seo: page.value.seo || {},
status: page.value.status || 'draft'
};
await pagesService.updatePage(route.params.id, pageData);
// Перенаправляем на просмотр страницы
router.push({ name: 'page-view', params: { id: route.params.id } });
} catch (error) {
console.error('Ошибка при сохранении страницы:', error);
alert('Ошибка при сохранении страницы: ' + (error?.message || error));
} finally {
isSubmitting.value = false;
}
}
// Загрузка данных
onMounted(async () => {
try {
page.value = await pagesService.getPage(route.params.id);
// Инициализируем SEO объект если его нет
if (!page.value.seo) {
page.value.seo = {
title: '',
description: '',
keywords: ''
};
}
} catch (error) {
console.error('Ошибка при загрузке страницы:', error);
alert('Ошибка при загрузке страницы: ' + (error?.message || error));
}
});
</script>
<style scoped>
.page-edit-page {
padding: 20px;
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header-content {
flex: 1;
}
.header-content h1 {
color: var(--color-primary);
font-size: 2.5rem;
margin: 0 0 10px 0;
}
.header-content p {
color: var(--color-grey-dark);
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: var(--color-grey-dark);
cursor: pointer;
padding: 0 10px;
transition: color 0.3s ease;
}
.close-btn:hover {
color: var(--color-primary);
}
.content-block {
background: #f8f9fa;
border-radius: var(--radius-lg);
padding: 25px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.content-form {
background: white;
border-radius: var(--radius-sm);
padding: 30px;
border: 1px solid #e9ecef;
max-width: 1000px;
margin: 0 auto;
}
.form-section {
margin-bottom: 30px;
}
.form-section:last-child {
margin-bottom: 0;
}
.form-section h2 {
color: var(--color-primary);
margin: 0 0 20px 0;
font-size: 1.3rem;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--color-grey-dark);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 12px 15px;
border: 1px solid #e9ecef;
border-radius: var(--radius-sm);
font-size: 1rem;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(45, 114, 217, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.content-stats {
display: flex;
gap: 20px;
margin-top: 8px;
font-size: 0.9rem;
color: var(--color-grey-dark);
}
.form-actions {
display: flex;
gap: 15px;
justify-content: flex-end;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.btn-outline {
background: white;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.btn-outline:hover {
background: var(--color-primary);
color: white;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-state p {
color: var(--color-grey-dark);
font-size: 1.1rem;
margin: 0;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 15px;
}
.header-content h1 {
font-size: 2rem;
}
.form-actions {
flex-direction: column;
}
.content-stats {
flex-direction: column;
gap: 5px;
}
}
</style>

View File

@@ -39,14 +39,10 @@
</div>
</div>
<div class="header-actions">
<button class="btn btn-outline" @click="goToEdit">
<button v-if="canEditData && address" class="btn btn-outline" @click="goToEdit">
<i class="fas fa-edit"></i>
Редактировать
</button>
<button class="btn btn-danger" @click="deletePage">
<i class="fas fa-trash"></i>
Удалить
</button>
<button class="close-btn" @click="goBack">×</button>
</div>
</div>
@@ -66,7 +62,21 @@
<div class="content-section">
<h2>Содержание</h2>
<div class="main-content">
<div v-if="page.content" v-html="formatContent(page.content)"></div>
<!-- HTML -->
<div v-if="page.format === 'html' && page.content" v-html="formatContent(page.content)"></div>
<!-- PDF -->
<div v-else-if="page.format === 'pdf' && page.file_path" class="file-preview">
<embed :src="page.file_path" type="application/pdf" class="pdf-embed" />
<a class="btn btn-outline" :href="page.file_path" target="_blank" download>Скачать PDF</a>
</div>
<!-- Image -->
<div v-else-if="page.format === 'image' && page.file_path" class="file-preview">
<img :src="page.file_path" alt="Документ" class="image-preview" />
<a class="btn btn-outline" :href="page.file_path" target="_blank" download>Скачать изображение</a>
</div>
<div v-else class="empty-content">
<i class="fas fa-file-alt"></i>
<p>Контент не добавлен</p>
@@ -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;

View File

@@ -51,13 +51,35 @@
<!-- Основной контент -->
<div class="page-content">
<h2>Содержание</h2>
<div class="content-text" v-html="formatContent(page.content)"></div>
<div class="content-text" v-if="page.format === 'html'" v-html="formatContent(page.content)"></div>
<div v-else-if="page.format === 'pdf' && page.file_path" class="file-preview">
<embed :src="page.file_path" type="application/pdf" class="pdf-embed" />
<a class="btn btn-outline" :href="page.file_path" target="_blank" download>Скачать PDF</a>
</div>
<div v-else-if="page.format === 'image' && page.file_path" class="file-preview">
<img :src="page.file_path" alt="Документ" class="image-preview" />
<a class="btn btn-outline" :href="page.file_path" target="_blank" download>Скачать изображение</a>
</div>
<div v-else class="content-text">Контент не добавлен</div>
</div>
<!-- SEO информация -->
<div v-if="page.seo" class="page-seo">
<h2>SEO информация</h2>
<div class="seo-content" v-html="formatContent(page.seo)"></div>
<div class="seo-info">
<div class="seo-item">
<label>Meta Title:</label>
<span>{{ page.seo.title || 'Не указан' }}</span>
</div>
<div class="seo-item">
<label>Meta Description:</label>
<span>{{ page.seo.description || 'Не указан' }}</span>
</div>
<div class="seo-item">
<label>Keywords:</label>
<span>{{ page.seo.keywords || 'Не указаны' }}</span>
</div>
</div>
</div>
</div>
@@ -141,6 +163,7 @@ function formatAddress(address) {
function formatContent(content) {
if (!content) return '';
if (typeof content !== 'string') return '';
// Простое форматирование - замена переносов строк на <br>
return content.replace(/\n/g, '<br>');
}
@@ -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 {

View File

@@ -0,0 +1,146 @@
<!--
Copyright (c) 2024-2025
-->
<template>
<BaseLayout :is-authenticated="isAuthenticated" :identities="identities" :token-balances="tokenBalances" :is-loading-tokens="isLoadingTokens" @auth-action-completed="$emit('auth-action-completed')">
<div class="list-page">
<div class="page-header">
<div class="header-content">
<h1>Публичные документы</h1>
<p>Публичные документы, доступные пользователям</p>
</div>
<button class="close-btn" @click="goBack">×</button>
</div>
<div class="content-block">
<div class="section-header">
<h2>Список документов</h2>
<div class="search-box">
<input v-model="search" type="text" placeholder="Поиск..." class="search-input" />
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div v-if="filtered.length" class="pages-grid">
<div v-for="p in filtered" :key="p.id" class="page-card" @click="openPublic(p.id)">
<div class="page-card-header">
<h3>{{ p.title }}</h3>
</div>
<div class="page-card-content">
<p class="page-summary">{{ p.summary || 'Без описания' }}</p>
<div class="page-meta">
<span class="page-status published"><i class="fas fa-circle"></i>Опубликовано</span>
<span class="page-status"><i class="fas fa-file"></i>{{ p.format || 'html' }}</span>
</div>
<div v-if="canManageLegalDocs && address" class="page-actions">
<button class="action-btn primary" title="Индексировать" @click.stop="reindex(p.id)"><i class="fas fa-sync"></i><span>Индекс</span></button>
<button class="action-btn primary" title="Редактировать" @click.stop="goEdit(p.id)"><i class="fas fa-edit"></i><span>Ред.</span></button>
<button class="action-btn danger" title="Удалить" @click.stop="doDelete(p.id)"><i class="fas fa-trash"></i><span>Удалить</span></button>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon"><i class="fas fa-file-alt"></i></div>
<h3>Нет опубликованных документов</h3>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { 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';
import { PERMISSIONS as SHARED_PERMISSIONS } from '/app/shared/permissions.js';
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 search = ref('');
const pages = ref([]);
const { address } = useAuthContext();
const { hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => hasPermission(SHARED_PERMISSIONS.MANAGE_LEGAL_DOCS));
function goBack() { router.push({ name: 'content-list' }); }
function openPublic(id) { router.push({ name: 'public-page-view', params: { id } }); }
function goEdit(id) { router.push({ name: 'page-edit', params: { id } }); }
async function reindex(id) {
try {
await api.post(`/pages/${id}/reindex`);
alert('Индексация выполнена');
} catch (e) {
alert('Ошибка индексации: ' + (e?.response?.data?.error || e.message));
}
}
async function doDelete(id) {
if (!confirm('Удалить документ?')) return;
try {
await pagesService.deletePage(id);
pages.value = await pagesService.getPublicPages();
} catch (e) {
alert('Ошибка удаления: ' + (e?.response?.data?.error || e.message));
}
}
const filtered = computed(() => {
return pages.value.filter(p =>
p.visibility === 'public' &&
p.status === 'published' &&
(!search.value || p.title?.toLowerCase().includes(search.value.toLowerCase()))
);
});
onMounted(async () => {
try {
pages.value = await pagesService.getPublicPages();
} catch (e) {
pages.value = [];
}
});
</script>
<style scoped>
.list-page { padding: 20px; width: 100%; }
.page-header { display:flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #f0f0f0; }
.header-content h1 { color: var(--color-primary); font-size: 2.2rem; margin: 0 0 8px 0; }
.header-content p { color: var(--color-grey-dark); margin: 0; }
.close-btn { background:none; border:none; font-size: 1.5rem; cursor:pointer; color:#888; }
/* Переиспользуем стили из TemplatesListView */
.content-block { background: #f8f9fa; border-radius: var(--radius-lg); padding: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.section-header { display:flex; justify-content: space-between; align-items:center; margin-bottom: 20px; }
.section-header h2 { color: var(--color-primary); margin: 0; }
.search-box { position: relative; width: 300px; }
.search-input { width: 100%; padding: 10px 40px 10px 15px; border: 1px solid #e9ecef; border-radius: var(--radius-sm); font-size: 1rem; }
.search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); color: var(--color-grey-dark); }
.pages-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-top: 10px; }
.page-card { background: #fff; border: 1px solid #e9ecef; border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: all 0.2s; }
.page-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
.page-card-header h3 { margin: 0; color: var(--color-primary); font-size: 1.2rem; }
.page-summary { color: var(--color-grey-dark); margin: 8px 0 12px; }
.page-meta { display:flex; gap: 12px; font-size: 0.9rem; color: var(--color-grey-dark); align-items: center; flex-wrap: wrap; }
.page-status i { margin-right: 6px; }
.page-status.published i { color: #4caf50; }
.page-actions { display:flex; gap: 10px; margin-top: 12px; }
.action-btn { display:inline-flex; align-items:center; gap:6px; background: #fff; color:#333; border: 1px solid #d0d7de; padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 0.95rem; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.action-btn i { font-size: 0.95rem; }
.action-btn:hover { background: #f6f8fa; border-color: #c2cbd3; }
.action-btn.primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.action-btn.primary:hover { background: var(--color-primary-dark); }
.action-btn.danger { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.action-btn.danger:hover { background: #fee2e2; }
.empty-state { text-align:center; padding: 60px 20px; }
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
</style>

View File

@@ -0,0 +1,151 @@
<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
-->
<template>
<BaseLayout :is-authenticated="isAuthenticated" :identities="identities" :token-balances="tokenBalances" :is-loading-tokens="isLoadingTokens" @auth-action-completed="$emit('auth-action-completed')">
<div class="list-page">
<div class="page-header">
<div class="header-content">
<h1>Шаблоны документов</h1>
<p>Системные шаблоны для персонализации и публикации.</p>
</div>
<div class="header-actions">
<button class="close-btn" @click="goBack">×</button>
</div>
</div>
<div class="content-block">
<div class="section-header">
<h2>Список шаблонов</h2>
<div class="filters">
<div class="filter-group">
<label for="visibility-filter">Видимость:</label>
<select v-model="visibilityFilter" id="visibility-filter" class="filter-select">
<option value="">Все</option>
<option value="public">Публичные</option>
<option value="internal">Внутренние</option>
</select>
</div>
<div class="search-box">
<input v-model="search" type="text" placeholder="Поиск шаблонов..." class="search-input" />
<i class="fas fa-search search-icon"></i>
</div>
</div>
</div>
<div v-if="filtered.length" class="pages-grid">
<div v-for="p in filtered" :key="p.id" class="page-card" @click="open(p.id)">
<div class="page-card-header">
<h3>{{ p.title }}</h3>
</div>
<div class="page-card-content">
<p class="page-summary">{{ p.summary || 'Без описания' }}</p>
<div class="page-meta">
<span class="page-status draft"><i class="fas fa-circle"></i>Черновик</span>
<span class="page-status"><i class="fas fa-cube"></i>Шаблон</span>
<span class="page-status" :class="p.visibility"><i class="fas fa-eye"></i>{{ p.visibility === 'internal' ? 'Внутренний' : 'Публичный' }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon"><i class="fas fa-file-alt"></i></div>
<h3>Шаблонов не найдено</h3>
<p v-if="!canEditData || !address">Требуются права редактора и подключённый кошелёк для просмотра системных шаблонов.</p>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService';
import { useAuthContext } from '../../composables/useAuth';
import { usePermissions } from '../../composables/usePermissions';
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 search = ref('');
const visibilityFilter = ref('');
const pages = ref([]);
const { address } = useAuthContext();
const { canEditData } = usePermissions();
function goBack() { router.push({ name: 'content-list' }); }
function open(id) {
if (canEditData.value && address.value) {
router.push({ name: 'page-view', params: { id } });
} else {
router.push({ name: 'public-page-view', params: { id } });
}
}
const filtered = computed(() => {
return pages.value.filter(p =>
(p.is_system_template === true) &&
(p.visibility === 'public' || p.visibility === 'internal') &&
(!visibilityFilter.value || p.visibility === visibilityFilter.value) &&
(!search.value || p.title?.toLowerCase().includes(search.value.toLowerCase()))
);
});
onMounted(async () => {
try {
if (canEditData.value && address.value) {
try {
pages.value = await pagesService.getPages();
} catch (e) {
pages.value = [];
}
} else {
pages.value = [];
}
} catch (e) {
pages.value = [];
}
});
</script>
<style scoped>
.list-page { padding: 20px; width: 100%; }
.page-header { display:flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #f0f0f0; }
.header-content h1 { color: var(--color-primary); font-size: 2.2rem; margin: 0 0 8px 0; }
.header-content p { color: var(--color-grey-dark); margin: 0; }
.close-btn { background:none; border:none; font-size: 1.5rem; cursor:pointer; color:#888; }
/* Переиспользуем стили из ContentListView */
.content-block { background: #f8f9fa; border-radius: var(--radius-lg); padding: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.section-header { display:flex; justify-content: space-between; align-items:center; margin-bottom: 20px; }
.section-header h2 { color: var(--color-primary); margin: 0; }
.filters { display: flex; gap: 20px; align-items: center; }
.filter-group { display: flex; align-items: center; gap: 8px; }
.filter-group label { color: var(--color-grey-dark); font-weight: 500; }
.filter-select { padding: 8px 12px; border: 1px solid #e9ecef; border-radius: var(--radius-sm); background: #fff; font-size: 1rem; }
.search-box { position: relative; width: 300px; }
.search-input { width: 100%; padding: 10px 40px 10px 15px; border: 1px solid #e9ecef; border-radius: var(--radius-sm); font-size: 1rem; }
.search-icon { position: absolute; right: 15px; top: 50%; transform: translateY(-50%); color: var(--color-grey-dark); }
.pages-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-top: 10px; }
.page-card { background: #fff; border: 1px solid #e9ecef; border-radius: var(--radius-sm); padding: 16px; cursor: pointer; transition: all 0.2s; }
.page-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
.page-card-header h3 { margin: 0; color: var(--color-primary); font-size: 1.2rem; }
.page-summary { color: var(--color-grey-dark); margin: 8px 0 12px; }
.page-meta { display:flex; gap: 12px; font-size: 0.9rem; color: var(--color-grey-dark); align-items: center; flex-wrap: wrap; }
.page-status i { margin-right: 6px; }
.page-status.draft i { color: #ff9800; }
.empty-state { text-align:center; padding: 60px 20px; }
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
</style>

View File

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