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

This commit is contained in:
2025-11-24 21:56:35 +03:00
parent 2087ba2d37
commit 9b0e133118
16 changed files with 5053 additions and 262 deletions

View File

@@ -68,7 +68,8 @@ const ensureDirectoriesExist = () => {
path.join(__dirname, 'contracts-data/dles'),
path.join(__dirname, 'temp'),
path.join(__dirname, 'uploads'),
path.join(__dirname, 'uploads/logos')
path.join(__dirname, 'uploads/logos'),
path.join(__dirname, 'uploads/content')
];
for (const dir of directories) {
@@ -201,8 +202,9 @@ app.use((req, res, next) => {
});
// Настройка парсеров
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Убираем ограничение по размеру (база данных масштабируется)
app.use(express.json({ limit: '50mb' })); // Увеличен лимит для JSON (для больших данных)
app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Увеличен лимит для URL-encoded
// Режим работы уже определен выше (при настройке trust proxy)

View File

@@ -27,9 +27,32 @@ const universalMediaProcessor = require('../services/UniversalMediaProcessor');
const consentService = require('../services/consentService');
const { DOCUMENT_CONSENT_MAP } = consentService;
// Настройка multer для обработки файлов в памяти
// Настройка multer для обработки файлов в памяти с лимитами для чата
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// Multer с лимитами для чата:
// - Изображения: до 100 МБ
// - Видео, аудио и другие файлы: до 300 МБ
const upload = multer({
storage: storage,
limits: {
fileSize: 300 * 1024 * 1024, // Максимальный лимит (300 МБ для видео, аудио и файлов)
files: 10 // Максимальное количество файлов за раз
},
fileFilter: (req, file, cb) => {
const isImage = /^image\/(png|jpg|jpeg|gif|webp|svg)$/i.test(file.mimetype || '');
const isVideo = /^video\/(mp4|webm|ogg|mov|avi)$/i.test(file.mimetype || '');
const isAudio = /^audio\/(mp3|wav|ogg|m4a|aac|flac|wma)$/i.test(file.mimetype || '');
// Разрешаем изображения, видео, аудио и другие файлы
if (isImage || isVideo || isAudio) {
cb(null, true);
} else {
// Разрешаем и другие файлы (документы и т.д.)
cb(null, true);
}
}
});
// Функция processGuestMessages заменена на UniversalGuestService.migrateToUser()
@@ -79,6 +102,20 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
for (const file of files) {
try {
// Проверяем размер файла перед обработкой
const isImage = /^image\//i.test(file.mimetype || '');
const isVideo = /^video\//i.test(file.mimetype || '');
const isAudio = /^audio\//i.test(file.mimetype || '');
// Лимиты: изображения - 100 МБ, видео/аудио/файлы - 300 МБ
const maxSize = isImage ? 100 * 1024 * 1024 : 300 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = Math.round(maxSize / (1024 * 1024));
const fileType = isImage ? 'изображений' : (isVideo ? 'видео' : (isAudio ? 'аудио' : 'файлов'));
throw new Error(`Размер файла "${file.originalname}" (${Math.round(file.size / (1024 * 1024))} МБ) превышает максимально допустимый размер (${maxSizeMB} МБ) для ${fileType}`);
}
const processedFile = await universalMediaProcessor.processFile(
file.buffer,
file.originalname,
@@ -264,13 +301,33 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
// Создаем identifier для пользователя
const identifier = `wallet:${walletIdentity.provider_id}`;
// Обработка вложений
const attachments = files.map(file => ({
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
data: file.buffer
}));
// Обработка вложений с проверкой размера
const attachments = [];
for (const file of files) {
// Проверяем размер файла перед обработкой
const isImage = /^image\//i.test(file.mimetype || '');
const isVideo = /^video\//i.test(file.mimetype || '');
const isAudio = /^audio\//i.test(file.mimetype || '');
// Лимиты: изображения - 100 МБ, видео/аудио/файлы - 300 МБ
const maxSize = isImage ? 100 * 1024 * 1024 : 300 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = Math.round(maxSize / (1024 * 1024));
const fileType = isImage ? 'изображений' : (isVideo ? 'видео' : (isAudio ? 'аудио' : 'файлов'));
return res.status(400).json({
success: false,
error: `Размер файла "${file.originalname}" (${Math.round(file.size / (1024 * 1024))} МБ) превышает максимально допустимый размер (${maxSizeMB} МБ) для ${fileType}`
});
}
attachments.push({
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
data: file.buffer
});
}
const messageData = {
identifier: identifier,

View File

@@ -132,7 +132,19 @@ router.post('/', upload.single('file'), async (req, res) => {
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
checksum: bodyRaw.checksum || null,
// Нормализуем категорию: приводим к нижнему регистру для консистентности
category: (bodyRaw.category && String(bodyRaw.category).trim()) ? String(bodyRaw.category).trim().toLowerCase() : null,
// Обрабатываем parent_id: может быть null или числом
parent_id: (bodyRaw.parent_id && bodyRaw.parent_id !== 'null' && bodyRaw.parent_id !== '')
? (() => { const parsed = parseInt(bodyRaw.parent_id); return isNaN(parsed) ? null : parsed; })()
: null,
// Обрабатываем order_index: должно быть числом
order_index: (bodyRaw.order_index && bodyRaw.order_index !== 'null' && bodyRaw.order_index !== '')
? (() => { const parsed = parseInt(bodyRaw.order_index); return isNaN(parsed) ? 0 : parsed; })()
: 0,
nav_path: bodyRaw.nav_path || null,
is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true'
};
// Формируем SQL для вставки данных (только непустые поля)
@@ -189,6 +201,159 @@ router.get('/', async (req, res) => {
res.json(rows);
});
// ========== РОУТЫ ДЛЯ КАТЕГОРИЙ (должны быть ПЕРЕД параметрическими роутами типа /:id) ==========
// Создать категорию
router.post('/categories', 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 create categories' });
}
try {
const { name, display_name, description, order_index } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Название категории обязательно' });
}
const normalizedName = name.trim().toLowerCase();
const displayName = display_name || name.trim();
// Проверяем, не существует ли уже категория с таким названием
const existsRes = await db.getQuery()(
`SELECT id FROM document_categories WHERE name = $1`,
[normalizedName]
);
if (existsRes.rows.length > 0) {
return res.status(409).json({ error: 'Категория с таким названием уже существует' });
}
// Создаем категорию
const { rows } = await db.getQuery()(
`INSERT INTO document_categories (name, display_name, description, order_index, author_address)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[normalizedName, displayName, description || null, order_index || 0, req.session.address]
);
console.log(`[pages] POST /categories: создана категория "${normalizedName}"`);
res.json(rows[0]);
} catch (error) {
console.error('[pages] Ошибка создания категории:', error);
// Если таблица не существует, возвращаем успех (для обратной совместимости)
if (error.message.includes('does not exist') || error.message.includes('relation')) {
console.warn('[pages] Таблица document_categories не существует, пропускаем создание');
res.json({ name: req.body.name.trim().toLowerCase(), display_name: req.body.name.trim() });
} else {
res.status(500).json({ error: 'Ошибка создания категории: ' + error.message });
}
}
});
// Удалить категорию
router.delete('/categories/:name', 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 delete categories' });
}
try {
const categoryName = decodeURIComponent(req.params.name).toLowerCase();
if (categoryName === 'uncategorized') {
return res.status(400).json({ error: 'Нельзя удалить категорию "Без категории"' });
}
// Удаляем категорию
const { rows } = await db.getQuery()(
`DELETE FROM document_categories WHERE name = $1 RETURNING *`,
[categoryName]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Категория не найдена' });
}
console.log(`[pages] DELETE /categories/:name: удалена категория "${categoryName}"`);
res.json({ success: true, deleted: rows[0] });
} catch (error) {
console.error('[pages] Ошибка удаления категории:', error);
// Если таблица не существует, возвращаем успех (для обратной совместимости)
if (error.message.includes('does not exist') || error.message.includes('relation')) {
console.warn('[pages] Таблица document_categories не существует, пропускаем удаление');
res.json({ success: true });
} else {
res.status(500).json({ error: 'Ошибка удаления категории: ' + error.message });
}
}
});
// Получить список всех категорий (для выпадающего списка)
router.get('/categories', async (req, res) => {
try {
// Сначала пытаемся получить категории из таблицы document_categories
try {
const categoriesRes = await db.getQuery()(
`SELECT name, display_name FROM document_categories ORDER BY order_index, created_at`
);
if (categoriesRes.rows.length > 0) {
const categories = categoriesRes.rows.map(row => row.name);
return res.json(categories);
}
} catch (err) {
console.warn('[pages] Таблица document_categories не существует, используем старый метод');
}
// Fallback: получаем категории из документов
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 { rows } = await db.getQuery()(`
SELECT DISTINCT category
FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND category IS NOT NULL
AND category != ''
ORDER BY category ASC
`);
const categories = rows.map(row => row.category).filter(Boolean);
res.json(categories);
} catch (error) {
console.error('Ошибка получения категорий:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// ========== КОНЕЦ РОУТОВ ДЛЯ КАТЕГОРИЙ ==========
// Получить одну страницу по id (только для админа)
router.get('/:id', async (req, res) => {
if (!req.session || !req.session.authenticated) {
@@ -354,10 +519,45 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
const incoming = req.body || {};
const updateData = {};
console.log(`[pages] PATCH /:id (${req.params.id}): получены данные для обновления:`, incoming);
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 (k === 'category') {
updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null;
}
// Обрабатываем parent_id: может быть null или числом
else if (k === 'parent_id') {
if (v === null || v === 'null' || v === '' || v === undefined) {
updateData[k] = null;
} else {
const parsed = parseInt(v);
updateData[k] = isNaN(parsed) ? null : parsed;
}
}
// Обрабатываем order_index: должно быть числом
else if (k === 'order_index') {
if (v === null || v === 'null' || v === '' || v === undefined) {
updateData[k] = 0;
} else {
const parsed = parseInt(v);
updateData[k] = isNaN(parsed) ? 0 : parsed;
}
}
// Обрабатываем is_index_page: должно быть boolean
else if (k === 'is_index_page') {
updateData[k] = v === true || v === 'true' || v === 1 || v === '1';
}
// Остальные поля
else {
updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v;
}
}
console.log(`[pages] PATCH /:id (${req.params.id}): обработанные данные для обновления:`, updateData);
if (req.file) {
updateData.format = req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf';
updateData.mime_type = req.file.mimetype || null;
@@ -372,10 +572,22 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
values.push(req.params.id);
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`;
console.log(`[pages] PATCH /:id (${req.params.id}): SQL запрос:`, sql);
console.log(`[pages] PATCH /:id (${req.params.id}): значения:`, values);
const { rows } = await db.getQuery()(sql, values);
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
const updated = rows[0];
console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, {
id: updated.id,
title: updated.title,
category: updated.category,
parent_id: updated.parent_id,
order_index: updated.order_index,
is_index_page: updated.is_index_page
});
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при обновлении отключена
@@ -442,16 +654,331 @@ router.get('/public/all', async (req, res) => {
return res.json([]);
}
// Получаем все опубликованные страницы всех админов
// Поддержка фильтрации по категории и родителю
const { category, parent_id, search } = req.query;
let whereClause = `WHERE visibility = 'public' AND status = 'published'`;
const params = [];
let paramIndex = 1;
if (category) {
whereClause += ` AND category = $${paramIndex}`;
params.push(category);
paramIndex++;
}
if (parent_id !== undefined) {
if (parent_id === null || parent_id === 'null' || parent_id === '') {
whereClause += ` AND parent_id IS NULL`;
} else {
const parsed = parseInt(parent_id);
if (!isNaN(parsed)) {
whereClause += ` AND parent_id = $${paramIndex}`;
params.push(parsed);
paramIndex++;
}
}
}
if (search) {
whereClause += ` AND (title ILIKE $${paramIndex} OR summary ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// Сортировка: сначала по категории, затем по order_index, затем по created_at
const { rows } = await db.getQuery()(`
SELECT * FROM ${tableName}
WHERE status = 'published'
ORDER BY created_at DESC
`);
${whereClause}
ORDER BY
COALESCE(category, '') ASC,
COALESCE(order_index, 0) ASC,
created_at DESC
`, params);
console.log(`[pages] GET /public/all: найдено ${rows.length} публичных документов`);
if (rows.length > 0) {
console.log(`[pages] Примеры документов:`, rows.slice(0, 3).map(r => ({ id: r.id, title: r.title, category: r.category })));
}
res.json(rows);
} catch (error) {
console.error('Ошибка получения публичных страниц:', error);
// Возвращаем пустой массив вместо объекта с ошибкой, чтобы фронтенд не ломался
console.error('[pages] GET /public/all: ошибка, возвращаем пустой массив');
res.status(500).json([]);
}
});
// Получить иерархическую структуру всех публичных страниц
router.get('/public/structure', async (req, res) => {
try {
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({ categories: [] });
}
// Получаем все опубликованные публичные страницы
const { rows } = await db.getQuery()(`
SELECT
id,
title,
summary,
category,
parent_id,
order_index,
nav_path,
is_index_page,
created_at
FROM ${tableName}
WHERE visibility = 'public' AND status = 'published'
ORDER BY
COALESCE(category, '') ASC,
COALESCE(order_index, 0) ASC,
created_at ASC
`);
// Группируем по категориям и строим иерархию
const categories = {};
const pagesById = {};
console.log(`[pages] GET /public/structure: найдено ${rows.length} страниц`);
rows.forEach(page => {
pagesById[page.id] = {
...page,
children: []
};
// Нормализуем категорию: приводим к нижнему регистру для консистентности
const cat = (page.category && String(page.category).trim())
? String(page.category).trim().toLowerCase()
: 'uncategorized';
if (!categories[cat]) {
categories[cat] = {
name: cat,
pages: []
};
}
});
// Строим иерархию
rows.forEach(page => {
const pageObj = pagesById[page.id];
if (page.parent_id && pagesById[page.parent_id]) {
// Дочерний документ - добавляем в children родителя
pagesById[page.parent_id].children.push(pageObj);
} else {
// Родительский документ (без parent_id) - добавляем в категорию
// Нормализуем категорию: приводим к нижнему регистру для консистентности
const cat = (page.category && String(page.category).trim())
? String(page.category).trim().toLowerCase()
: 'uncategorized';
// Убеждаемся, что категория существует (на случай, если она была создана только в первом цикле)
if (!categories[cat]) {
categories[cat] = {
name: cat,
pages: []
};
}
categories[cat].pages.push(pageObj);
}
});
// Сортируем children внутри каждого родительского документа
Object.values(categories).forEach(cat => {
cat.pages.forEach(page => {
if (page.children && Array.isArray(page.children) && page.children.length > 0) {
page.children.sort((a, b) => {
if (a.order_index !== b.order_index) {
return (a.order_index || 0) - (b.order_index || 0);
}
return new Date(a.created_at) - new Date(b.created_at);
});
}
});
});
// Загружаем все категории из таблицы document_categories (включая пустые)
try {
const categoriesRes = await db.getQuery()(
`SELECT name, display_name, description, order_index
FROM document_categories
ORDER BY order_index, created_at`
);
categoriesRes.rows.forEach(cat => {
const normalizedName = cat.name.toLowerCase();
if (!categories[normalizedName]) {
// Добавляем пустую категорию, если её нет в списке из документов
categories[normalizedName] = {
name: normalizedName,
pages: []
};
}
// Обновляем отображаемое название
if (cat.display_name) {
categories[normalizedName].display_name = cat.display_name;
}
});
} catch (err) {
console.warn('[pages] Ошибка загрузки категорий из document_categories (таблица может не существовать):', err.message);
}
console.log(`[pages] GET /public/structure: создано ${Object.keys(categories).length} категорий:`, Object.keys(categories));
// Сортируем страницы в категориях: сначала родительские (с детьми), потом остальные
Object.values(categories).forEach(cat => {
cat.pages.sort((a, b) => {
// Сначала документы с детьми (родительские)
const aHasChildren = a.children && Array.isArray(a.children) && a.children.length > 0;
const bHasChildren = b.children && Array.isArray(b.children) && b.children.length > 0;
// Если a имеет детей, а b нет - a идет первым (отрицательное значение = a перед b)
if (aHasChildren && !bHasChildren) return -1;
// Если b имеет детей, а a нет - b идет первым (положительное значение = b перед a)
if (!aHasChildren && bHasChildren) return 1;
// Если оба с детьми или оба без детей, сортируем по order_index и created_at
if (a.order_index !== b.order_index) {
return (a.order_index || 0) - (b.order_index || 0);
}
return new Date(a.created_at) - new Date(b.created_at);
});
// Логируем результат сортировки для отладки
console.log(`[pages] Категория "${cat.name}": порядок документов:`,
cat.pages.map(p => ({
id: p.id,
title: p.title,
hasChildren: p.children && Array.isArray(p.children) && p.children.length > 0,
childrenCount: p.children ? p.children.length : 0
}))
);
});
const result = {
categories: Object.values(categories),
totalPages: rows.length
};
// Логируем результат для отладки
console.log(`[pages] GET /public/structure: возвращаем ${result.categories.length} категорий`);
result.categories.forEach(cat => {
console.log(` - ${cat.name}: ${cat.pages.length} страниц`);
});
res.json(result);
} catch (error) {
console.error('Ошибка получения структуры страниц:', error);
// Возвращаем пустую структуру вместо объекта с ошибкой, чтобы фронтенд не ломался
console.error('[pages] GET /public/structure: ошибка, возвращаем пустую структуру');
res.status(500).json({ categories: [], totalPages: 0 });
}
});
// Получить навигацию для конкретного документа
router.get('/public/:id/navigation', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
const pageId = parseInt(req.params.id);
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.status(404).json({ error: 'Страница не найдена' });
}
// Получаем текущую страницу
const { rows: currentPage } = await db.getQuery()(`
SELECT * FROM ${tableName}
WHERE id = $1 AND visibility = 'public' AND status = 'published'
`, [pageId]);
if (currentPage.length === 0) {
return res.status(404).json({ error: 'Страница не найдена' });
}
const page = currentPage[0];
const category = page.category || null;
const parentId = page.parent_id || null;
// Получаем все страницы той же категории/родителя для навигации
let whereClause = `WHERE visibility = 'public' AND status = 'published'`;
const params = [];
let paramIndex = 1;
if (parentId) {
whereClause += ` AND parent_id = $${paramIndex}`;
params.push(parentId);
paramIndex++;
} else if (category) {
whereClause += ` AND category = $${paramIndex} AND (parent_id IS NULL OR parent_id = 0)`;
params.push(category);
paramIndex++;
} else {
whereClause += ` AND (category IS NULL OR category = '') AND (parent_id IS NULL OR parent_id = 0)`;
}
const { rows: siblings } = await db.getQuery()(`
SELECT id, title, nav_path, order_index
FROM ${tableName}
${whereClause}
ORDER BY COALESCE(order_index, 0) ASC, created_at ASC
`, params);
// Находим текущую страницу в списке
const currentIndex = siblings.findIndex(p => p.id === pageId);
const prevPage = currentIndex > 0 ? siblings[currentIndex - 1] : null;
const nextPage = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null;
// Получаем родительскую страницу
let parentPage = null;
if (parentId) {
const { rows: parent } = await db.getQuery()(`
SELECT id, title, nav_path FROM ${tableName}
WHERE id = $1 AND visibility = 'public' AND status = 'published'
`, [parentId]);
if (parent.length > 0) {
parentPage = parent[0];
}
}
// Формируем breadcrumbs
const breadcrumbs = [];
if (category) {
breadcrumbs.push({ name: category, path: null });
}
if (parentPage) {
breadcrumbs.push({ name: parentPage.title, path: `/content/published/${parentPage.id}` });
}
breadcrumbs.push({ name: page.title, path: null });
res.json({
previous: prevPage ? {
id: prevPage.id,
title: prevPage.title,
path: `/content/published/${prevPage.id}`
} : null,
next: nextPage ? {
id: nextPage.id,
title: nextPage.title,
path: `/content/published/${nextPage.id}`
} : null,
parent: parentPage ? {
id: parentPage.id,
title: parentPage.title,
path: `/content/published/${parentPage.id}`
} : null,
breadcrumbs
});
} catch (error) {
console.error('Ошибка получения навигации:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});

View File

@@ -17,6 +17,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const crypto = require('crypto');
const auth = require('../middleware/auth');
const router = express.Router();
@@ -59,6 +60,354 @@ router.post('/logo', auth.requireAuth, auth.requireAdmin, upload.single('logo'),
}
});
// Хранилище для медиа-файлов контента в памяти (для сохранения в БД)
// Ограничение по размеру не установлено - база данных масштабируется
const mediaUpload = multer({
storage: multer.memoryStorage(), // Храним в памяти для сохранения в БД
// limits: { fileSize: ... } - убрано, нет ограничений по размеру
fileFilter: (req, file, cb) => {
// Разрешаем изображения и видео
const isImage = /^image\/(png|jpg|jpeg|gif|webp|svg)$/i.test(file.mimetype || '');
const isVideo = /^video\/(mp4|webm|ogg|mov|avi)$/i.test(file.mimetype || '');
if (!isImage && !isVideo) {
return cb(new Error('Разрешены только изображения (PNG, JPG, GIF, WEBP, SVG) и видео (MP4, WEBM, OGG, MOV, AVI)'));
}
cb(null, true);
}
});
// POST /api/uploads/media (form field: media) - для загрузки изображений и видео для контента
// Используем те же права, что и для создания страниц (требуется аутентификация и права редактора/админа)
router.post('/media', auth.requireAuth, async (req, res) => {
// Проверяем права доступа (редактор или админ)
if (!req.session.address) {
return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' });
}
// Используем middleware для загрузки файла
mediaUpload.single('media')(req, res, async (err) => {
if (err) {
return res.status(400).json({ success: false, message: err.message });
}
try {
if (!req.file || !req.file.buffer) return res.status(400).json({ success: false, message: 'Файл не получен' });
const db = require('../db');
const mediaType = req.file.mimetype.startsWith('image/') ? 'image' : 'video';
// Вычисляем SHA-256 хеш файла для дедупликации
const fileHash = crypto.createHash('sha256').update(req.file.buffer).digest('hex');
// Проверяем, не загружен ли уже такой файл
const existingFile = await db.getQuery()(
'SELECT id, file_name FROM content_media WHERE file_hash = $1',
[fileHash]
);
let mediaId;
let fileName;
if (existingFile.rows.length > 0) {
// Файл уже существует - возвращаем существующую запись
mediaId = existingFile.rows[0].id;
fileName = existingFile.rows[0].file_name;
} else {
// Сохраняем новый файл в базу данных
const { rows } = await db.getQuery()(`
INSERT INTO content_media (
file_data,
file_name,
mime_type,
file_size,
file_hash,
media_type,
author_address,
page_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, file_name
`, [
req.file.buffer, // BYTEA данные
req.file.originalname || 'unnamed',
req.file.mimetype,
req.file.size,
fileHash,
mediaType,
req.session.address,
req.body.page_id || null
]);
mediaId = rows[0].id;
fileName = rows[0].file_name;
}
// URL для доступа к файлу через API
const fileUrl = `/api/uploads/media/${mediaId}/file`;
// Используем относительный URL, чтобы frontend сам формировал полный URL
// Это позволяет работать с разными портами (frontend на 9000, backend на 8000)
const fullUrl = fileUrl;
return res.json({
success: true,
data: {
id: mediaId,
url: fullUrl,
type: mediaType,
filename: fileName,
originalName: req.file.originalname || 'unnamed',
mimeType: req.file.mimetype,
size: req.file.size,
hash: fileHash,
isDuplicate: existingFile.rows.length > 0
}
});
} catch (e) {
console.error('Ошибка сохранения медиа в БД:', e);
return res.status(500).json({ success: false, message: e.message });
}
});
});
// GET /api/uploads/media/:id/file - получить файл по ID
router.get('/media/:id/file', async (req, res) => {
try {
const db = require('../db');
const mediaId = parseInt(req.params.id);
const { rows } = await db.getQuery()(
'SELECT file_data, file_name, mime_type, file_size FROM content_media WHERE id = $1',
[mediaId]
);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: 'Медиа-файл не найден' });
}
const media = rows[0];
// Устанавливаем заголовки для правильной отдачи файла
res.setHeader('Content-Type', media.mime_type);
res.setHeader('Content-Length', media.file_size);
res.setHeader('Content-Disposition', `inline; filename="${media.file_name}"`);
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Кеширование на 1 год
// Отправляем бинарные данные
res.send(media.file_data);
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// GET /api/uploads/media - получить список медиа-файлов
router.get('/media', auth.requireAuth, async (req, res) => {
try {
if (!req.session.address) {
return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' });
}
const db = require('../db');
const { page_id, media_type, limit = 50, offset = 0 } = req.query;
let whereClause = 'WHERE 1=1';
const params = [];
let paramIndex = 1;
if (page_id) {
whereClause += ` AND page_id = $${paramIndex}`;
params.push(parseInt(page_id));
paramIndex++;
}
if (media_type) {
whereClause += ` AND media_type = $${paramIndex}`;
params.push(media_type);
paramIndex++;
}
params.push(parseInt(limit));
params.push(parseInt(offset));
const { rows } = await db.getQuery()(`
SELECT
id,
page_id,
file_name,
mime_type,
file_size,
file_hash,
media_type,
alt_text,
title,
description,
author_address,
created_at,
updated_at
FROM content_media
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Добавляем URL для каждого файла
const protocol = req.protocol || 'http';
const host = req.get('host') || 'localhost:8000';
const mediaWithUrls = rows.map(media => ({
...media,
url: `${protocol}://${host}/api/uploads/media/${media.id}/file`
}));
const { rows: countRows } = await db.getQuery()(`
SELECT COUNT(*) as total
FROM content_media
${whereClause}
`, params.slice(0, -2));
return res.json({
success: true,
data: mediaWithUrls,
total: parseInt(countRows[0].total),
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// PATCH /api/uploads/media/:id - обновить метаданные медиа (например, связать с документом)
router.patch('/media/:id', auth.requireAuth, async (req, res) => {
try {
if (!req.session.address) {
return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' });
}
const db = require('../db');
const mediaId = parseInt(req.params.id);
const { page_id, alt_text, title, description } = req.body;
const updates = [];
const params = [];
let paramIndex = 1;
if (page_id !== undefined) {
updates.push(`page_id = $${paramIndex}`);
params.push(page_id ? parseInt(page_id) : null);
paramIndex++;
}
if (alt_text !== undefined) {
updates.push(`alt_text = $${paramIndex}`);
params.push(alt_text || null);
paramIndex++;
}
if (title !== undefined) {
updates.push(`title = $${paramIndex}`);
params.push(title || null);
paramIndex++;
}
if (description !== undefined) {
updates.push(`description = $${paramIndex}`);
params.push(description || null);
paramIndex++;
}
if (updates.length === 0) {
return res.status(400).json({ success: false, message: 'Нет полей для обновления' });
}
params.push(mediaId);
const { rows } = await db.getQuery()(`
UPDATE content_media
SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = $${paramIndex}
RETURNING *
`, params);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: 'Медиа-файл не найден' });
}
return res.json({ success: true, data: rows[0] });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
// DELETE /api/uploads/media/:id - удалить медиа-файл
router.delete('/media/:id', auth.requireAuth, async (req, res) => {
try {
if (!req.session.address) {
return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' });
}
const db = require('../db');
const mediaId = parseInt(req.params.id);
// Проверяем существование файла
const { rows: mediaRows } = await db.getQuery()(
'SELECT id, file_hash FROM content_media WHERE id = $1',
[mediaId]
);
if (mediaRows.length === 0) {
return res.status(404).json({ success: false, message: 'Медиа-файл не найден' });
}
// Проверяем, используется ли файл в других документах (если есть file_hash)
const fileHash = mediaRows[0].file_hash;
if (fileHash) {
const { rows: usageRows } = await db.getQuery()(
'SELECT COUNT(*) as count FROM content_media WHERE file_hash = $1',
[fileHash]
);
// Если файл используется в нескольких местах, не удаляем данные, только связь
if (parseInt(usageRows[0].count) > 1) {
// Просто удаляем связь с документом, но оставляем файл
await db.getQuery()(
'UPDATE content_media SET page_id = NULL WHERE id = $1',
[mediaId]
);
return res.json({ success: true, message: 'Связь с документом удалена, файл сохранен (используется в других местах)' });
}
}
// Удаляем запись из БД (файл удалится вместе с записью)
await db.getQuery()('DELETE FROM content_media WHERE id = $1', [mediaId]);
return res.json({ success: true, message: 'Медиа-файл удален' });
} catch (e) {
return res.status(500).json({ success: false, message: e.message });
}
});
module.exports = router;

View File

@@ -543,45 +543,8 @@ class UniversalGuestService {
};
}
// Проверяем согласия для добавления системного сообщения к ответу ИИ
const consentService = require('./consentService');
const [provider, providerId] = identifier?.split(':') || [];
let walletAddress = null;
if (provider === 'web' && providerId?.startsWith('guest_')) {
walletAddress = providerId; // Для веб-гостей используем guest_ID
}
const consentCheck = await consentService.checkConsents({
userId: null,
walletAddress
});
// Формируем финальный ответ ИИ с системным сообщением, если нужно
// 4. Сохраняем AI ответ
let finalAiResponse = aiResponse.response;
let consentInfo = null;
if (consentCheck.needsConsent) {
const consentSystemMessage = await consentService.getConsentSystemMessage({
userId: null,
walletAddress,
channel: channel === 'web' ? 'web' : channel
});
if (consentSystemMessage && consentSystemMessage.consentRequired) {
// Добавляем системное сообщение к ответу ИИ
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
consentInfo = {
consentRequired: true,
missingConsents: consentSystemMessage.missingConsents,
consentDocuments: consentSystemMessage.consentDocuments,
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
};
}
}
// 4. Сохраняем AI ответ с добавленным системным сообщением
await this.saveAiResponse({
identifier,
content: finalAiResponse,
@@ -600,14 +563,6 @@ class UniversalGuestService {
}
};
// Добавляем информацию о согласиях, если они нужны
if (consentInfo) {
result.consentRequired = consentInfo.consentRequired;
result.missingConsents = consentInfo.missingConsents;
result.consentDocuments = consentInfo.consentDocuments;
result.autoConsentOnReply = consentInfo.autoConsentOnReply;
}
return result;
} catch (error) {

View File

@@ -380,30 +380,10 @@ async function processMessage(messageData) {
});
if (aiResponse && aiResponse.success && aiResponse.response) {
// Проверяем согласия и добавляем системное сообщение к ответу ИИ
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
const consentSystemMessage = await consentService.getConsentSystemMessage({
userId,
walletAddress: walletIdentity?.provider_id || null,
channel: channel === 'web' ? 'web' : channel
});
// Формируем финальный ответ ИИ с системным сообщением, если нужно
// Формируем финальный ответ ИИ
finalAiResponse = aiResponse.response;
if (consentSystemMessage && consentSystemMessage.consentRequired) {
// Добавляем системное сообщение к ответу ИИ
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
// Сохраняем информацию о согласиях в метаданные ответа
aiResponse.consentInfo = {
consentRequired: true,
missingConsents: consentSystemMessage.missingConsents,
consentDocuments: consentSystemMessage.consentDocuments,
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
};
}
// Сохраняем ответ AI с добавленным системным сообщением
// Сохраняем ответ AI
const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages (
conversation_id,
@@ -478,14 +458,6 @@ async function processMessage(messageData) {
assistantDisabled: aiResponseDisabled
};
// Если есть информация о согласиях, добавляем её в результат
if (aiResponse && aiResponse.success && aiResponse.consentInfo) {
result.consentRequired = aiResponse.consentInfo.consentRequired;
result.missingConsents = aiResponse.consentInfo.missingConsents;
result.consentDocuments = aiResponse.consentInfo.consentDocuments;
result.autoConsentOnReply = aiResponse.consentInfo.autoConsentOnReply;
}
return result;
} catch (error) {

View File

@@ -6,6 +6,9 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0;
# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;

View File

@@ -6,6 +6,9 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0;
# Rate limiting для защиты от DDoS
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s;

View File

@@ -25,10 +25,13 @@
"ethers": "6.13.5",
"marked": "^15.0.7",
"papaparse": "^5.5.3",
"quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"siwe": "^2.1.4",
"sortablejs": "^1.15.6",
"vue": "^3.2.47",
"vue-i18n": "^11.1.2",
"vue-quill": "^1.5.1",
"vue-router": "^4.1.6",
"vuex": "^4.1.0"
},

View File

@@ -0,0 +1,831 @@
<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/VC-HB3-Accelerator
-->
<template>
<div class="docs-content">
<!-- Хлебные крошки -->
<!-- Убираем последний элемент breadcrumbs, так как он дублирует заголовок страницы -->
<nav v-if="breadcrumbs.length > 1" class="breadcrumbs">
<a
v-for="(crumb, index) in breadcrumbs.slice(0, -1)"
:key="index"
:href="crumb.path || '#'"
:class="['breadcrumb-item', { active: !crumb.path }]"
@click.prevent="crumb.path && navigateTo(crumb.path)"
>
{{ crumb.name }}
<i v-if="index < breadcrumbs.slice(0, -1).length - 1" class="fas fa-chevron-right breadcrumb-separator"></i>
</a>
</nav>
<!-- Заголовок страницы -->
<header v-if="page" class="page-header">
<div class="page-header-top">
<button v-if="breadcrumbs.length > 0" class="back-btn" @click="$emit('back')" title="Вернуться к списку">
<i class="fas fa-arrow-left"></i>
<span>Назад</span>
</button>
<div v-if="canManageDocs" class="page-header-actions">
<button
class="page-action-btn page-edit-btn"
@click="editPage"
title="Редактировать документ"
>
<i class="fas fa-edit"></i>
<span>Редактировать</span>
</button>
<button
class="page-action-btn page-delete-btn"
@click="confirmDeletePage"
title="Удалить документ"
>
<i class="fas fa-trash"></i>
<span>Удалить</span>
</button>
</div>
</div>
<h1>{{ page.title }}</h1>
<div v-if="page.summary" class="page-summary">
{{ page.summary }}
</div>
<div class="page-meta">
<span class="meta-item">
<i class="fas fa-calendar"></i>
{{ formatDate(page.created_at) }}
</span>
<span v-if="page.category" class="meta-item">
<i class="fas fa-folder"></i>
{{ page.category }}
</span>
</div>
</header>
<!-- Основной контент -->
<article v-if="page" class="page-article">
<div v-if="page.format === 'html'" class="content-text" v-html="formatContent"></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="empty-content">
<i class="fas fa-file-alt"></i>
<p>Контент не добавлен</p>
</div>
</article>
<!-- Навигация: Предыдущая/Следующая -->
<nav v-if="navigation" class="page-navigation">
<div class="nav-section">
<a
v-if="navigation.previous"
:href="navigation.previous.path"
class="nav-link nav-prev"
@click.prevent="navigateTo(navigation.previous.path)"
>
<div class="nav-label">
<i class="fas fa-arrow-left"></i>
<span>Предыдущая</span>
</div>
<div class="nav-title">{{ navigation.previous.title }}</div>
</a>
<div v-else class="nav-link nav-prev disabled">
<div class="nav-label">
<i class="fas fa-arrow-left"></i>
<span>Предыдущая</span>
</div>
</div>
</div>
<div class="nav-section">
<a
v-if="navigation.next"
:href="navigation.next.path"
class="nav-link nav-next"
@click.prevent="navigateTo(navigation.next.path)"
>
<div class="nav-label">
<span>Следующая</span>
<i class="fas fa-arrow-right"></i>
</div>
<div class="nav-title">{{ navigation.next.title }}</div>
</a>
<div v-else class="nav-link nav-next disabled">
<div class="nav-label">
<span>Следующая</span>
<i class="fas fa-arrow-right"></i>
</div>
</div>
</div>
</nav>
<!-- Загрузка -->
<div v-else-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка документа...</p>
</div>
<!-- Ошибка -->
<div v-else class="error-state">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3>Документ не найден</h3>
<p>Запрашиваемый документ не существует или не опубликован</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import pagesService from '../../services/pagesService';
import { usePermissions } from '../../composables/usePermissions';
import { PERMISSIONS } from '../../composables/permissions';
const props = defineProps({
pageId: {
type: Number,
default: null
}
});
const emit = defineEmits(['back']);
const router = useRouter();
const route = useRoute();
const { hasPermission } = usePermissions();
const canManageDocs = computed(() => hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS));
const page = ref(null);
const navigation = ref(null);
const breadcrumbs = ref([]);
const isLoading = ref(false);
// Загрузка страницы
async function loadPage() {
if (!props.pageId) return;
try {
isLoading.value = true;
page.value = await pagesService.getPublicPage(props.pageId);
// Загружаем навигацию
try {
navigation.value = await pagesService.getPublicPageNavigation(props.pageId);
breadcrumbs.value = navigation.value.breadcrumbs || [];
} catch (navError) {
console.warn('Ошибка загрузки навигации:', navError);
navigation.value = null;
breadcrumbs.value = [];
}
} catch (error) {
console.error('Ошибка загрузки страницы:', error);
page.value = null;
} finally {
isLoading.value = false;
}
}
// Форматирование контента
const formatContent = computed(() => {
if (!page.value || !page.value.content) return '';
let content = page.value.content;
const title = page.value.title || '';
// Удаляем первый заголовок из контента, если он совпадает с title страницы
// Это предотвращает дублирование заголовка
// Сначала проверяем, есть ли заголовок в HTML формате (если контент уже HTML)
if (content.includes('<h1') || content.includes('<H1')) {
// Удаляем заголовки h1 из HTML, если они совпадают с title
content = content.replace(/<h1[^>]*>([^<]*)<\/h1>/gi, (match, headerText) => {
const text = headerText.trim();
if (text.toLowerCase() === title.toLowerCase()) {
return ''; // Удаляем заголовок
}
return match; // Оставляем заголовок
});
}
// Удаляем заголовки markdown (# Title, ## Title и т.д.) в начале контента
const lines = content.split('\n');
let startIndex = 0;
// Пропускаем пустые строки в начале
while (startIndex < lines.length && lines[startIndex].trim() === '') {
startIndex++;
}
// Проверяем, является ли первая непустая строка заголовком markdown
if (startIndex < lines.length) {
const firstLine = lines[startIndex];
const headerMatch = firstLine.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const headerText = headerMatch[2].trim();
// Если заголовок совпадает с title страницы, удаляем его
if (headerText.toLowerCase() === title.toLowerCase()) {
lines.splice(startIndex, 1);
// Удаляем следующую пустую строку, если есть
if (startIndex < lines.length && lines[startIndex].trim() === '') {
lines.splice(startIndex, 1);
}
content = lines.join('\n');
}
}
// Также проверяем, не является ли первая строка просто текстом, совпадающим с заголовком
const firstLineText = firstLine.trim();
if (firstLineText.toLowerCase() === title.toLowerCase() && !firstLineText.match(/^[#<]/)) {
// Если первая строка - это просто текст, совпадающий с заголовком, удаляем её
lines.splice(startIndex, 1);
// Удаляем следующую пустую строку, если есть
if (startIndex < lines.length && lines[startIndex].trim() === '') {
lines.splice(startIndex, 1);
}
content = lines.join('\n');
}
}
// Проверяем, является ли контент markdown
const isMarkdown = /^#{1,6}\s|^\*\s|^\-\s|^\d+\.\s|```|\[.+\]\(.+\)|!\[.+\]\(.+\)/m.test(content);
if (isMarkdown) {
const rawHtml = marked.parse(content);
// Разрешаем теги video и их атрибуты для корректного отображения видео
let sanitizedHtml = DOMPurify.sanitize(rawHtml, {
ADD_TAGS: ['video', 'source'],
ADD_ATTR: ['controls', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'playsinline']
});
// Еще раз удаляем заголовки h1 из HTML после парсинга markdown
sanitizedHtml = sanitizedHtml.replace(/<h1[^>]*>([^<]*)<\/h1>/gi, (match, headerText) => {
const text = headerText.trim();
if (text.toLowerCase() === title.toLowerCase()) {
return ''; // Удаляем заголовок
}
return match; // Оставляем заголовок
});
// Удаляем пустые строки и теги в начале
sanitizedHtml = sanitizedHtml.replace(/^\s*(<br\s*\/?>|<p>\s*<\/p>)\s*/i, '');
sanitizedHtml = sanitizedHtml.trim();
return sanitizedHtml;
} else {
// Для обычного текста также удаляем первую строку, если она совпадает с заголовком
const textLines = content.split('\n');
if (textLines.length > 0 && textLines[0].trim().toLowerCase() === title.toLowerCase()) {
textLines.shift();
// Удаляем следующую пустую строку, если есть
if (textLines.length > 0 && textLines[0].trim() === '') {
textLines.shift();
}
content = textLines.join('\n');
}
return content.replace(/\n/g, '<br>');
}
});
function formatDate(date) {
if (!date) return 'Не указана';
return new Date(date).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function navigateTo(path) {
// Поддержка разных форматов путей
const match1 = path.match(/\/content\/published\/(\d+)/);
const match2 = path.match(/\/content\/published\?page=(\d+)/);
const match3 = path.match(/\/public\/page\/(\d+)/);
const pageId = match1?.[1] || match2?.[1] || match3?.[1];
if (pageId) {
router.push({ name: 'content-published', query: { page: pageId } });
}
}
// Редактирование документа
function editPage() {
if (!page.value || !page.value.id) return;
router.push({ name: 'content-create', query: { edit: page.value.id } });
}
// Подтверждение удаления документа
async function confirmDeletePage() {
if (!page.value || !page.value.id) return;
if (!confirm(`Вы уверены, что хотите удалить документ "${page.value.title}"?\n\nЭто действие нельзя отменить.`)) {
return;
}
try {
console.log('[DocsContent] Удаление документа:', page.value.id);
await pagesService.deletePage(page.value.id);
console.log('[DocsContent] Документ успешно удален');
// Возвращаемся к списку документов
emit('back');
// Уведомляем другие компоненты об обновлении
window.dispatchEvent(new CustomEvent('docs-structure-updated'));
} catch (error) {
console.error('[DocsContent] Ошибка удаления документа:', error);
alert('Ошибка удаления: ' + (error.response?.data?.error || error.message || 'Неизвестная ошибка'));
}
}
// Отслеживаем изменения pageId
watch(() => props.pageId, (newId, oldId) => {
console.log('[DocsContent] pageId изменился:', { oldId, newId });
if (newId && newId !== oldId) {
console.log('[DocsContent] Загружаем страницу:', newId);
loadPage();
} else if (!newId) {
// Если pageId стал null, очищаем страницу
page.value = null;
navigation.value = null;
breadcrumbs.value = [];
}
}, { immediate: true });
onMounted(() => {
if (props.pageId) {
loadPage();
}
});
</script>
<style scoped>
.docs-content {
flex: 1;
max-width: 900px;
margin: 0 auto;
padding: 40px;
min-height: 100%;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
margin-bottom: 16px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
color: #495057;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
background: #e9ecef;
border-color: var(--color-primary);
color: var(--color-primary);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 0.9rem;
flex-wrap: wrap;
}
.breadcrumb-item {
color: #6c757d;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.2s;
}
.breadcrumb-item:hover:not(.active) {
color: var(--color-primary);
}
.breadcrumb-item.active {
color: #495057;
font-weight: 500;
}
.breadcrumb-separator {
font-size: 0.7rem;
color: #adb5bd;
}
.page-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 2px solid #e9ecef;
}
.page-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header-actions {
display: flex;
gap: 8px;
}
.page-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid #e9ecef;
border-radius: 6px;
background: #fff;
color: #495057;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.page-action-btn:hover {
border-color: var(--color-primary);
}
.page-edit-btn:hover {
background: #e7f3ff;
color: var(--color-primary);
}
.page-delete-btn:hover {
background: #fee;
color: #dc3545;
border-color: #dc3545;
}
.page-header h1 {
margin: 0 0 16px 0;
font-size: 2.5rem;
color: var(--color-primary);
font-weight: 700;
line-height: 1.2;
}
.page-summary {
font-size: 1.1rem;
color: #6c757d;
margin-bottom: 16px;
line-height: 1.6;
}
.page-meta {
display: flex;
gap: 20px;
font-size: 0.9rem;
color: #6c757d;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.meta-item i {
font-size: 0.85rem;
}
.page-article {
margin-bottom: 48px;
}
.content-text {
line-height: 1.7;
color: #333;
}
.content-text :deep(h1),
.content-text :deep(h2),
.content-text :deep(h3),
.content-text :deep(h4) {
color: var(--color-primary);
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.content-text :deep(h1) {
font-size: 2rem;
border-bottom: 2px solid #e9ecef;
padding-bottom: 0.5rem;
}
.content-text :deep(h2) {
font-size: 1.5rem;
}
.content-text :deep(h3) {
font-size: 1.25rem;
}
.content-text :deep(p) {
margin-bottom: 1rem;
}
.content-text :deep(ul),
.content-text :deep(ol) {
margin: 1rem 0;
padding-left: 2rem;
}
.content-text :deep(li) {
margin: 0.5rem 0;
}
.content-text :deep(code) {
background: #f4f4f4;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.content-text :deep(pre) {
background: #f4f4f4;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
}
.content-text :deep(pre code) {
background: none;
padding: 0;
}
.content-text :deep(blockquote) {
border-left: 4px solid var(--color-primary);
padding-left: 1rem;
margin: 1.5rem 0;
color: #666;
font-style: italic;
}
.content-text :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.content-text :deep(table th),
.content-text :deep(table td) {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.content-text :deep(table th) {
background: #f8f9fa;
font-weight: 600;
}
.content-text :deep(a) {
color: var(--color-primary);
text-decoration: none;
}
.content-text :deep(a:hover) {
text-decoration: underline;
}
/* Стили для изображений в контенте */
.content-text :deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1.5rem 0;
display: block;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.content-text :deep(img:hover) {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Стили для видео в контенте */
.content-text :deep(video) {
max-width: 100%;
width: 100%;
height: auto;
min-height: 300px;
border-radius: 8px;
margin: 1.5rem 0;
display: block;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #000;
}
.content-text :deep(video:focus) {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.file-preview {
display: flex;
flex-direction: column;
gap: 16px;
margin: 2rem 0;
}
.pdf-embed {
width: 100%;
height: 70vh;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.image-preview {
max-width: 100%;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn-outline {
background: white;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.btn-outline:hover {
background: var(--color-primary);
color: white;
}
.empty-content {
text-align: center;
padding: 60px 20px;
color: #6c757d;
}
.empty-content i {
font-size: 3rem;
margin-bottom: 16px;
display: block;
}
.page-navigation {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 48px;
padding-top: 32px;
border-top: 2px solid #e9ecef;
}
.nav-link {
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid #e9ecef;
border-radius: 8px;
text-decoration: none;
color: #495057;
transition: all 0.2s;
}
.nav-link:hover:not(.disabled) {
border-color: var(--color-primary);
background: #f8f9fa;
color: var(--color-primary);
}
.nav-link.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nav-prev {
text-align: left;
}
.nav-next {
text-align: right;
}
.nav-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #6c757d;
margin-bottom: 8px;
font-weight: 500;
}
.nav-next .nav-label {
justify-content: flex-end;
}
.nav-title {
font-weight: 600;
color: #333;
}
.loading-state,
.error-state {
text-align: center;
padding: 60px 20px;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid var(--color-primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-icon {
font-size: 4rem;
color: #ff9800;
margin-bottom: 20px;
}
.error-state h3 {
color: var(--color-primary);
margin: 0 0 10px 0;
}
.error-state p {
color: #6c757d;
margin: 0;
}
@media (max-width: 768px) {
.docs-content {
padding: 20px;
}
.page-header h1 {
font-size: 2rem;
}
.page-navigation {
grid-template-columns: 1fr;
}
.nav-next {
text-align: left;
}
.nav-next .nav-label {
justify-content: flex-start;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/VC-HB3-Accelerator
-->
<template>
<div class="rich-text-editor">
<div ref="editorContainer" class="editor-container"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import api from '../../api/axios';
// Импортируем и регистрируем модуль изменения размера изображений
let ImageResize;
try {
ImageResize = require('quill-image-resize-module').default || require('quill-image-resize-module');
Quill.register('modules/imageResize', ImageResize);
} catch (error) {
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
}
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Введите текст...'
}
});
const emit = defineEmits(['update:modelValue']);
const editorContainer = ref(null);
let quill = null;
// Настройка Quill с панелью инструментов
const toolbarOptions = [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'font': [] }],
[{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'align': [] }],
['blockquote', 'code-block'],
['link', 'image', 'video'],
['clean']
];
onMounted(() => {
if (!editorContainer.value) return;
// Инициализация Quill
quill = new Quill(editorContainer.value, {
theme: 'snow',
placeholder: props.placeholder,
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
'image': handleImageClick,
'video': handleVideoClick
}
},
imageResize: {
parchment: Quill.import('parchment'),
modules: ['Resize', 'DisplaySize', 'Toolbar']
}
}
});
// Устанавливаем начальное значение
if (props.modelValue) {
quill.root.innerHTML = props.modelValue;
}
// Слушаем изменения
quill.on('text-change', () => {
const html = quill.root.innerHTML;
emit('update:modelValue', html);
});
});
// Обновление при изменении modelValue извне
watch(() => props.modelValue, (newValue) => {
if (quill && quill.root.innerHTML !== newValue) {
quill.root.innerHTML = newValue || '';
}
});
// Обработка вставки изображения
function handleImageClick() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
console.log('[RichTextEditor] Начало загрузки изображения:', file.name);
// Загружаем файл
const formData = new FormData();
formData.append('media', file);
const response = await api.post('/uploads/media', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('[RichTextEditor] Ответ от сервера:', response.data);
if (response.data && response.data.success && response.data.data && response.data.data.url) {
// Получаем текущую позицию курсора или используем конец документа
let range = quill.getSelection();
if (!range) {
// Если курсор не установлен, вставляем в конец
const length = quill.getLength();
range = { index: length - 1, length: 0 };
}
// Используем полный URL для доступа к файлу
let fullUrl = response.data.data.url;
if (!fullUrl.startsWith('http')) {
// Если URL относительный, добавляем origin
fullUrl = `${window.location.origin}${fullUrl.startsWith('/') ? '' : '/'}${fullUrl}`;
}
console.log('[RichTextEditor] Вставляем изображение по URL:', fullUrl, 'в позицию:', range.index);
// Вставляем изображение
quill.insertEmbed(range.index, 'image', fullUrl);
// Перемещаем курсор после изображения
quill.setSelection(range.index + 1, 0);
// Принудительно обновляем modelValue
const html = quill.root.innerHTML;
emit('update:modelValue', html);
console.log('[RichTextEditor] Изображение успешно вставлено');
} else {
console.error('[RichTextEditor] Неверный формат ответа:', response.data);
alert('Ошибка: сервер вернул неверный формат данных');
}
} catch (error) {
console.error('[RichTextEditor] Ошибка загрузки изображения:', error);
console.error('[RichTextEditor] Детали ошибки:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
});
alert('Ошибка загрузки изображения: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
}
};
}
// Обработка вставки видео
function handleVideoClick() {
// Предлагаем выбор: загрузить файл или вставить URL
const choice = confirm('Нажмите OK для загрузки файла или Отмена для вставки URL');
if (choice) {
// Загрузка файла
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'video/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
console.log('[RichTextEditor] Начало загрузки видео:', file.name);
// Загружаем файл
const formData = new FormData();
formData.append('media', file);
const response = await api.post('/uploads/media', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('[RichTextEditor] Ответ от сервера:', response.data);
if (response.data && response.data.success && response.data.data && response.data.data.url) {
// Получаем текущую позицию курсора или используем конец документа
let range = quill.getSelection();
if (!range) {
// Если курсор не установлен, вставляем в конец
const length = quill.getLength();
range = { index: length - 1, length: 0 };
}
// Используем полный URL для доступа к файлу
let fullUrl = response.data.data.url;
if (!fullUrl.startsWith('http')) {
// Если URL относительный, добавляем origin
fullUrl = `${window.location.origin}${fullUrl.startsWith('/') ? '' : '/'}${fullUrl}`;
}
console.log('[RichTextEditor] Вставляем видео по URL:', fullUrl, 'в позицию:', range.index);
// Вставляем видео
quill.insertEmbed(range.index, 'video', fullUrl);
// Перемещаем курсор после видео
quill.setSelection(range.index + 1, 0);
// Принудительно обновляем modelValue
const html = quill.root.innerHTML;
emit('update:modelValue', html);
console.log('[RichTextEditor] Видео успешно вставлено');
} else {
console.error('[RichTextEditor] Неверный формат ответа:', response.data);
alert('Ошибка: сервер вернул неверный формат данных');
}
} catch (error) {
console.error('[RichTextEditor] Ошибка загрузки видео:', error);
console.error('[RichTextEditor] Детали ошибки:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
});
alert('Ошибка загрузки видео: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
}
};
} else {
// Вставка URL
const url = prompt('Введите URL видео:');
if (url) {
let range = quill.getSelection();
if (!range) {
const length = quill.getLength();
range = { index: length - 1, length: 0 };
}
quill.insertEmbed(range.index, 'video', url);
quill.setSelection(range.index + 1, 0);
// Принудительно обновляем modelValue
const html = quill.root.innerHTML;
emit('update:modelValue', html);
}
}
}
onBeforeUnmount(() => {
if (quill) {
quill = null;
}
});
// Метод для получения HTML контента
function getHTML() {
return quill ? quill.root.innerHTML : '';
}
// Метод для установки HTML контента
function setHTML(html) {
if (quill) {
quill.root.innerHTML = html || '';
}
}
defineExpose({
getHTML,
setHTML
});
</script>
<style scoped>
.rich-text-editor {
width: 100%;
}
.editor-container {
min-height: 300px;
}
/* Переопределение стилей Quill для соответствия дизайну */
:deep(.ql-container) {
font-family: inherit;
font-size: 1rem;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
:deep(.ql-toolbar) {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: 1px solid #e9ecef;
background: #f8f9fa;
}
:deep(.ql-editor) {
min-height: 300px;
padding: 15px;
}
:deep(.ql-editor.ql-blank::before) {
font-style: normal;
color: #6c757d;
}
:deep(.ql-snow .ql-stroke) {
stroke: #495057;
}
:deep(.ql-snow .ql-fill) {
fill: #495057;
}
:deep(.ql-snow .ql-picker-label:hover),
:deep(.ql-snow .ql-picker-item:hover) {
color: var(--color-primary);
}
:deep(.ql-snow .ql-tooltip) {
background: white;
border: 1px solid #e9ecef;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:deep(.ql-snow .ql-tooltip input[type=text]) {
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 4px 8px;
}
:deep(.ql-snow img),
:deep(.ql-snow video) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* Стили для изменения размера изображений */
:deep(.ql-image-resize) {
display: inline-block;
position: relative;
}
:deep(.ql-image-resize .ql-image-resize-handle) {
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
background: var(--color-primary);
border: 2px solid white;
border-radius: 2px;
cursor: nwse-resize;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
:deep(.ql-snow img),
:deep(.ql-snow video) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
</style>

View File

@@ -28,8 +28,16 @@ export default {
return res.data;
},
async updatePage(id, data) {
const res = await api.patch(`/pages/${id}`, data);
return res.data;
console.log('[pagesService] updatePage:', { id, data });
try {
const res = await api.patch(`/pages/${id}`, data);
console.log('[pagesService] updatePage успешно:', res.data);
return res.data;
} catch (error) {
console.error('[pagesService] updatePage ошибка:', error);
console.error('[pagesService] updatePage ошибка response:', error.response?.data);
throw error;
}
},
async deletePage(id) {
const res = await api.delete(`/pages/${id}`);
@@ -37,8 +45,28 @@ export default {
},
// Публичные методы (доступны всем пользователям)
async getPublicPages() {
const res = await api.get('/pages/public/all');
async getPublicPages(params = {}) {
const queryParams = new URLSearchParams();
if (params.category) queryParams.append('category', params.category);
if (params.parent_id !== undefined) queryParams.append('parent_id', params.parent_id);
if (params.search) queryParams.append('search', params.search);
const url = `/pages/public/all${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const res = await api.get(url);
console.log('[pagesService] getPublicPages response:', {
status: res.status,
dataLength: Array.isArray(res.data) ? res.data.length : 'not array',
dataType: typeof res.data,
firstItem: Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : null
});
return res.data;
},
async getPublicPagesStructure() {
const res = await api.get('/pages/public/structure');
return res.data;
},
async getPublicPageNavigation(id) {
const res = await api.get(`/pages/public/${id}/navigation`);
return res.data;
},
async getInternalPages() {
@@ -49,4 +77,21 @@ export default {
const res = await api.get(`/pages/public/${id}`);
return res.data;
},
async getCategories() {
const res = await api.get('/pages/categories');
return res.data;
},
async createCategory(name, display_name, description, order_index) {
const res = await api.post('/pages/categories', {
name,
display_name,
description,
order_index
});
return res.data;
},
async deleteCategory(name) {
const res = await api.delete(`/pages/categories/${encodeURIComponent(name)}`);
return res.data;
},
};

View File

@@ -93,6 +93,29 @@
class="form-textarea"
/>
</div>
<div class="form-group">
<label for="category">Раздел</label>
<div class="category-select-wrapper">
<select
v-model="form.category"
id="category"
class="form-select"
>
<option value=""> Без раздела </option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat }}
</option>
</select>
<button
type="button"
class="btn btn-outline btn-add-section"
@click="handleAddSection"
>
<i class="fas fa-plus"></i>
Добавить раздел
</button>
</div>
</div>
</div>
<!-- Контент -->
@@ -100,13 +123,9 @@
<h2>Содержание</h2>
<div class="form-group" v-if="form.format === 'html'">
<label for="content">Основной контент *</label>
<textarea
<RichTextEditor
v-model="form.content"
id="content"
required
rows="10"
placeholder="Введите основной контент страницы"
class="form-textarea"
/>
<div class="content-stats">
<span>Слов: {{ wordCount }}</span>
@@ -177,6 +196,7 @@
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import RichTextEditor from '../components/editor/RichTextEditor.vue';
import pagesService from '../services/pagesService';
import { PERMISSIONS } from './permissions.js';
import { useAuthContext } from '../composables/useAuth';
@@ -234,9 +254,13 @@ const form = ref({
status: 'published',
visibility: 'public',
requiredPermission: '',
format: 'html'
format: 'html',
category: ''
});
// Список категорий
const categories = ref([]);
const isSubmitting = ref(false);
const fileBlob = ref(null);
const fileName = ref('');
@@ -266,6 +290,67 @@ function onFileChange(e) {
}
}
// Загрузка категорий
async function loadCategories() {
try {
const cats = await pagesService.getCategories();
categories.value = cats || [];
} catch (error) {
console.error('Ошибка загрузки категорий:', error);
categories.value = [];
}
}
// Обработка добавления нового раздела
async function handleAddSection() {
const newCategory = prompt('Введите название нового раздела:');
if (!newCategory || !newCategory.trim()) {
return;
}
const trimmedCategory = newCategory.trim();
const normalizedCategory = trimmedCategory.toLowerCase();
// Проверяем, не существует ли уже такая категория
if (categories.value.includes(normalizedCategory)) {
alert('Раздел с таким названием уже существует');
form.value.category = normalizedCategory;
return;
}
try {
// Создаем категорию через API
const createdCategory = await pagesService.createCategory(
normalizedCategory,
trimmedCategory, // display_name
null, // description
0 // order_index
);
console.log('[ContentPageView] Категория создана:', createdCategory);
// Обновляем список категорий
await loadCategories();
// Устанавливаем созданную категорию в форму
form.value.category = normalizedCategory;
alert(`Раздел "${trimmedCategory}" успешно создан`);
} catch (error) {
console.error('[ContentPageView] Ошибка создания раздела:', error);
const errorMessage = error.response?.data?.error || error.message || 'Неизвестная ошибка';
// Если категория уже существует на сервере, просто добавляем её в список
if (errorMessage.includes('уже существует') || error.response?.status === 409) {
await loadCategories();
form.value.category = normalizedCategory;
alert('Раздел с таким названием уже существует');
} else {
alert('Ошибка создания раздела: ' + errorMessage);
}
}
}
// Загрузка данных для редактирования
async function loadPageForEdit() {
if (!isEditMode.value || !editId.value) return;
@@ -283,6 +368,7 @@ async function loadPageForEdit() {
form.value.visibility = page.visibility || 'public';
form.value.requiredPermission = page.required_permission || '';
form.value.format = page.format || 'html';
form.value.category = page.category || '';
}
} catch (error) {
console.error('Ошибка загрузки страницы для редактирования:', error);
@@ -333,7 +419,8 @@ async function handleSubmit() {
: null,
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded'
storage_type: 'embedded',
category: form.value.category || null
};
page = await pagesService.updatePage(editId.value, pageData);
} else {
@@ -370,7 +457,8 @@ async function handleSubmit() {
: null,
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded'
storage_type: 'embedded',
category: form.value.category || null
};
page = await pagesService.createPage(pageData);
} else {
@@ -406,15 +494,18 @@ async function handleSubmit() {
}
// Загрузка данных при монтировании
onMounted(() => {
onMounted(async () => {
// Проверяем права доступа
if (!canManageLegalDocs.value || !address.value) {
router.push({ name: 'content-list' });
return;
}
// Загружаем категории
await loadCategories();
if (isEditMode.value) {
loadPageForEdit();
await loadPageForEdit();
}
});
</script>
@@ -544,6 +635,21 @@ onMounted(() => {
color: var(--color-grey-dark);
}
.category-select-wrapper {
display: flex;
gap: 10px;
align-items: flex-start;
}
.category-select-wrapper .form-select {
flex: 1;
}
.btn-add-section {
white-space: nowrap;
padding: 12px 16px;
}
.checkbox-label {
display: flex;
align-items: center;

File diff suppressed because it is too large Load Diff

View File

@@ -832,7 +832,7 @@ base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
boolbase@^1.0.0:
boolbase@^1.0.0, boolbase@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
@@ -860,7 +860,7 @@ buffer@^6.0.3:
base64-js "^1.3.1"
ieee754 "^1.2.1"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
@@ -868,6 +868,24 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
es-errors "^1.3.0"
function-bind "^1.1.2"
call-bind@^1.0.7, call-bind@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c"
integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==
dependencies:
call-bind-apply-helpers "^1.0.0"
es-define-property "^1.0.0"
get-intrinsic "^1.2.4"
set-function-length "^1.2.2"
call-bound@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
dependencies:
call-bind-apply-helpers "^1.0.2"
get-intrinsic "^1.3.0"
callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
@@ -902,6 +920,22 @@ chart.js@^4.5.1:
dependencies:
"@kurkle/color" "^0.3.0"
cheerio@^0.19.0:
version "0.19.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.19.0.tgz#772e7015f2ee29965096d71ea4175b75ab354925"
integrity sha512-Fwcm3zkR37STnPC8FepSHeSYJM5Rd596TZOcfDUdojR4Q735aK1Xn+M+ISagNneuCwMjK28w4kX+ETILGNT/UQ==
dependencies:
css-select "~1.0.0"
dom-serializer "~0.1.0"
entities "~1.1.1"
htmlparser2 "~3.8.1"
lodash "^3.2.0"
clone@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@@ -938,6 +972,11 @@ connect-pg-simple@^10.0.0:
dependencies:
pg "^8.12.0"
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cosmiconfig@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
@@ -963,6 +1002,21 @@ css-functions-list@^3.1.0:
resolved "https://registry.yarnpkg.com/css-functions-list/-/css-functions-list-3.2.3.tgz#95652b0c24f0f59b291a9fc386041a19d4f40dbe"
integrity sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==
css-select@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.0.0.tgz#b1121ca51848dd264e2244d058cee254deeb44b0"
integrity sha512-/xPlD7betkfd7ChGkLGGWx5HWyiHDOSn7aACLzdH0nwucPvB0EAm8hMBm7Xn7vGfAeRRN7KZ8wumGm8NoNcMRw==
dependencies:
boolbase "~1.0.0"
css-what "1.0"
domutils "1.4"
nth-check "~1.0.0"
css-what@1.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-1.0.0.tgz#d7cc2df45180666f99d2b14462639469e00f736c"
integrity sha512-60SUMPBreXrLXgvpM8kYpO0AOyMRhdRlXFX5BMQbZq1SIJCyNE56nqFQhmvREQdUJpedbGRYZ5wOyq3/F6q5Zw==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@@ -1005,11 +1059,41 @@ decamelize@^1.1.0, decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-equal@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.2.tgz#78a561b7830eef3134c7f6f3a3d6af272a678761"
integrity sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==
dependencies:
is-arguments "^1.1.1"
is-date-object "^1.0.5"
is-regex "^1.1.4"
object-is "^1.1.5"
object-keys "^1.1.1"
regexp.prototype.flags "^1.5.1"
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
define-data-property@^1.0.1, define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
dependencies:
es-define-property "^1.0.0"
es-errors "^1.3.0"
gopd "^1.0.1"
define-properties@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c"
integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==
dependencies:
define-data-property "^1.0.1"
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@@ -1022,6 +1106,14 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dom-serializer@0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
dependencies:
domelementtype "^2.0.1"
entities "^2.0.0"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
@@ -1031,11 +1123,31 @@ dom-serializer@^2.0.0:
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
dom-serializer@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==
dependencies:
domelementtype "^1.3.0"
entities "^1.1.1"
domelementtype@1, domelementtype@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
domelementtype@^2.0.1, domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@2.3:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.3.0.tgz#2de59a0822d5027fabff6f032c2b25a2a8abe738"
integrity sha512-q9bUwjfp7Eif8jWxxxPSykdRZAb6GkguBGSgvvCrhI9wB71W2K/Kvv4E61CF/mcCfnVJDeDWx/Vb/uAqbDj6UQ==
dependencies:
domelementtype "1"
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
@@ -1050,6 +1162,21 @@ dompurify@^3.2.4:
optionalDependencies:
"@types/trusted-types" "^2.0.7"
domutils@1.4:
version "1.4.3"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.4.3.tgz#0865513796c6b306031850e175516baf80b72a6f"
integrity sha512-ZkVgS/PpxjyJMb+S2iVHHEZjVnOUtjGp0/zstqKGTE9lrZtNHlNQmLwP/lhLMEApYbzc08BKMx9IFpKhaSbW1w==
dependencies:
domelementtype "1"
domutils@1.5:
version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
integrity sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==
dependencies:
dom-serializer "0"
domelementtype "1"
domutils@^3.0.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
@@ -1094,6 +1221,21 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
entities@1.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26"
integrity sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==
entities@^1.1.1, entities@~1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
entities@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
entities@^4.2.0, entities@^4.4.0, entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
@@ -1106,7 +1248,7 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-define-property@^1.0.1:
es-define-property@^1.0.0, es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
@@ -1329,12 +1471,32 @@ ethers@6.13.5:
tslib "2.7.0"
ws "8.17.1"
eventemitter3@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba"
integrity sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==
eventemitter3@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
extend@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-diff@^1.1.2:
fast-diff@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154"
integrity sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==
fast-diff@^1.1.2, fast-diff@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
@@ -1472,7 +1634,12 @@ function-bind@^1.1.2:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-intrinsic@^1.2.6:
functions-have-names@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
get-intrinsic@^1.2.4, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
@@ -1572,7 +1739,7 @@ globjoin@^0.1.4:
resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43"
integrity sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==
gopd@^1.2.0:
gopd@^1.0.1, gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
@@ -1587,6 +1754,13 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
dependencies:
es-define-property "^1.0.0"
has-symbols@^1.0.3, has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
@@ -1633,6 +1807,17 @@ htmlparser2@^8.0.0:
domutils "^3.0.1"
entities "^4.4.0"
htmlparser2@~3.8.1:
version "3.8.3"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.8.3.tgz#996c28b191516a8be86501a7d79757e5c70c1068"
integrity sha512-hBxEg3CYXe+rPIua8ETe7tmG3XDn9B0edOE/e9wH2nLczxzgdu0m0aNHY+5wFZiviLWLdANPJTssa92dMcXQ5Q==
dependencies:
domelementtype "1"
domhandler "2.3"
domutils "1.5"
entities "1.0"
readable-stream "1.1"
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -1674,7 +1859,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2:
inherits@2, inherits@~2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -1684,6 +1869,14 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
is-arguments@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b"
integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==
dependencies:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -1701,6 +1894,14 @@ is-core-module@^2.16.0, is-core-module@^2.5.0:
dependencies:
hasown "^2.0.2"
is-date-object@^1.0.5:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7"
integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==
dependencies:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -1733,6 +1934,21 @@ is-plain-object@^5.0.0:
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-regex@^1.1.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==
dependencies:
call-bound "^1.0.2"
gopd "^1.2.0"
has-tostringtag "^1.0.2"
hasown "^2.0.2"
isarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -1834,6 +2050,21 @@ lodash-unified@^1.0.3:
resolved "https://registry.yarnpkg.com/lodash-unified/-/lodash-unified-1.0.3.tgz#80b1eac10ed2eb02ed189f08614a29c27d07c894"
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.defaultsdeep@^4.6.0:
version "4.6.1"
resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6"
integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@@ -1844,7 +2075,12 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==
lodash@^4.17.21:
lodash@^3.2.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
integrity sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==
lodash@^4.17.21, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -2009,6 +2245,26 @@ nth-check@^2.1.1:
dependencies:
boolbase "^1.0.0"
nth-check@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
dependencies:
boolbase "~1.0.0"
object-is@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==
dependencies:
call-bind "^1.0.7"
define-properties "^1.2.1"
object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
@@ -2066,6 +2322,16 @@ papaparse@^5.5.3:
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==
parchment@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/parchment/-/parchment-1.1.4.tgz#aeded7ab938fe921d4c34bc339ce1168bc2ffde5"
integrity sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==
parchment@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/parchment/-/parchment-3.0.0.tgz#2e3a4ada454e1206ae76ea7afcb50e9fb517e7d6"
integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -2285,6 +2551,68 @@ quick-lru@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
quill-delta@^3.6.2:
version "3.6.3"
resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-3.6.3.tgz#b19fd2b89412301c60e1ff213d8d860eac0f1032"
integrity sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==
dependencies:
deep-equal "^1.0.1"
extend "^3.0.2"
fast-diff "1.1.2"
quill-delta@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48"
integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==
dependencies:
fast-diff "^1.3.0"
lodash.clonedeep "^4.5.0"
lodash.isequal "^4.5.0"
quill-image-resize-module@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/quill-image-resize-module/-/quill-image-resize-module-3.0.0.tgz#0fd93746a837336d95b2f536140416a623c71771"
integrity sha512-1TZBnUxU/WIx5dPyVjQ9yN7C6mLZSp04HyWBEMqT320DIq4MW4JgzlOPDZX5ZpBM3bU6sacU4kTLUc8VgYQZYw==
dependencies:
lodash "^4.17.4"
quill "^1.2.2"
raw-loader "^0.5.1"
quill-render@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/quill-render/-/quill-render-1.0.5.tgz#532870df74cd9ae8992a3ea194f1c86ab494a2dc"
integrity sha512-PJaOQXaYbVD9GCwR5fVz3S/OuarbNbvzfNu9EZK745qMpyWUAAbt9YUEY+cWWPwVwVIJ00XVJpQQl+xLA1gFEQ==
dependencies:
cheerio "^0.19.0"
escape-html "^1.0.3"
quill@^1.2.2, quill@^1.3.0:
version "1.3.7"
resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.7.tgz#da5b2f3a2c470e932340cdbf3668c9f21f9286e8"
integrity sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==
dependencies:
clone "^2.1.1"
deep-equal "^1.0.1"
eventemitter3 "^2.0.3"
extend "^3.0.2"
parchment "^1.1.4"
quill-delta "^3.6.2"
quill@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/quill/-/quill-2.0.3.tgz#752765a31d5a535cdc5717dc49d4e50099365eb1"
integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==
dependencies:
eventemitter3 "^5.0.1"
lodash-es "^4.17.21"
parchment "^3.0.0"
quill-delta "^5.1.0"
raw-loader@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa"
integrity sha512-sf7oGoLuaYAScB4VGr0tzetsYlS8EJH6qnTCfQ/WVEa89hALQ4RQfCKt5xCyPQKPDUbVUAIP1QsxAwfAjlDp7Q==
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
@@ -2304,6 +2632,16 @@ read-pkg@^5.2.0:
parse-json "^5.0.0"
type-fest "^0.6.0"
readable-stream@1.1:
version "1.1.13"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.13.tgz#f6eef764f514c89e2b9e23146a75ba106756d23e"
integrity sha512-E98tWzqShvKDGpR2MbjsDkDQWLW2TfWUC15H4tNQhIJ5Lsta84l8nUGL9/ybltGwe+wZzWPpc1Kmd2wQP4bdCA==
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
redent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
@@ -2312,6 +2650,18 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"
regexp.prototype.flags@^1.5.1:
version "1.5.4"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19"
integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==
dependencies:
call-bind "^1.0.8"
define-properties "^1.2.1"
es-errors "^1.3.0"
get-proto "^1.0.1"
gopd "^1.2.0"
set-function-name "^2.0.2"
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
@@ -2410,6 +2760,28 @@ semver@^7.3.4, semver@^7.3.5, semver@^7.3.6, semver@^7.6.3:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
dependencies:
define-data-property "^1.1.4"
es-errors "^1.3.0"
function-bind "^1.1.2"
get-intrinsic "^1.2.4"
gopd "^1.0.1"
has-property-descriptors "^1.0.2"
set-function-name@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985"
integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==
dependencies:
define-data-property "^1.1.4"
es-errors "^1.3.0"
functions-have-names "^1.2.3"
has-property-descriptors "^1.0.2"
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -2501,6 +2873,11 @@ string-width@^4.2.3:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==
strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -2780,6 +3157,15 @@ vue-i18n@^11.1.2:
"@intlify/shared" "11.1.12"
"@vue/devtools-api" "^6.5.0"
vue-quill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/vue-quill/-/vue-quill-1.5.1.tgz#dd84a6ca1617fe124edc76f2e7917ebda0d8df5a"
integrity sha512-4U3pMsBy2Vc4SSanZ6l1hzuB+m6vN9IERZzzOp+U2ziIcA6uGJYoxONmdPJkrMbcHpYYNrATqZOlWBuXqti30w==
dependencies:
lodash.defaultsdeep "^4.6.0"
quill "^1.3.0"
quill-render "^1.0.5"
vue-router@^4.1.6:
version "4.5.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.5.1.tgz#47bffe2d3a5479d2886a9a244547a853aa0abf69"