ваше сообщение коммита
This commit is contained in:
2
backend/db/migrations/025_add_direction_to_messages.sql
Normal file
2
backend/db/migrations/025_add_direction_to_messages.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Миграция: добавление поля direction в таблицу messages
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS direction VARCHAR(8);
|
||||||
@@ -6,9 +6,9 @@ const { requireAuth } = require('../middleware/auth');
|
|||||||
// const userService = require('../services/userService');
|
// const userService = require('../services/userService');
|
||||||
|
|
||||||
// Получение списка пользователей
|
// Получение списка пользователей
|
||||||
router.get('/', (req, res) => {
|
// router.get('/', (req, res) => {
|
||||||
res.json({ message: 'Users API endpoint' });
|
// res.json({ message: 'Users API endpoint' });
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Получение информации о пользователе
|
// Получение информации о пользователе
|
||||||
router.get('/:address', (req, res) => {
|
router.get('/:address', (req, res) => {
|
||||||
@@ -92,6 +92,36 @@ router.put('/profile', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Получение списка пользователей с контактами
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const usersResult = await db.getQuery()('SELECT id, first_name, last_name, created_at FROM users ORDER BY id');
|
||||||
|
const users = usersResult.rows;
|
||||||
|
// Получаем все user_identities разом
|
||||||
|
const identitiesResult = await db.getQuery()('SELECT user_id, provider, provider_id FROM user_identities');
|
||||||
|
const identities = identitiesResult.rows;
|
||||||
|
// Группируем идентификаторы по user_id
|
||||||
|
const identityMap = {};
|
||||||
|
for (const id of identities) {
|
||||||
|
if (!identityMap[id.user_id]) identityMap[id.user_id] = {};
|
||||||
|
if (!identityMap[id.user_id][id.provider]) identityMap[id.user_id][id.provider] = id.provider_id;
|
||||||
|
}
|
||||||
|
// Собираем контакты
|
||||||
|
const contacts = users.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
name: [u.first_name, u.last_name].filter(Boolean).join(' ') || null,
|
||||||
|
email: identityMap[u.id]?.email || null,
|
||||||
|
telegram: identityMap[u.id]?.telegram || null,
|
||||||
|
wallet: identityMap[u.id]?.wallet || null,
|
||||||
|
created_at: u.created_at
|
||||||
|
}));
|
||||||
|
res.json({ success: true, contacts });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching contacts:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/users - Получить список всех пользователей (пример, может требовать прав администратора)
|
// GET /api/users - Получить список всех пользователей (пример, может требовать прав администратора)
|
||||||
// В текущей реализации этот маршрут не используется и закомментирован
|
// В текущей реализации этот маршрут не используется и закомментирован
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { ethers } = require('ethers');
|
const { ethers } = require('ethers');
|
||||||
const emailBot = require('./services/emailBot');
|
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const { app, nonceStore } = require('./app');
|
const { app, nonceStore } = require('./app');
|
||||||
const usersRouter = require('./routes/users');
|
const usersRouter = require('./routes/users');
|
||||||
@@ -15,6 +14,7 @@ const { getBot, stopBot } = require('./services/telegramBot');
|
|||||||
const pgSession = require('connect-pg-simple')(session);
|
const pgSession = require('connect-pg-simple')(session);
|
||||||
const authService = require('./services/auth-service');
|
const authService = require('./services/auth-service');
|
||||||
const logger = require('./utils/logger');
|
const logger = require('./utils/logger');
|
||||||
|
const EmailBotService = require('./services/emailBot.js');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8000;
|
const PORT = process.env.PORT || 8000;
|
||||||
|
|
||||||
@@ -28,12 +28,20 @@ async function initServices() {
|
|||||||
console.log('Инициализация сервисов...');
|
console.log('Инициализация сервисов...');
|
||||||
|
|
||||||
// Останавливаем предыдущий экземпляр бота
|
// Останавливаем предыдущий экземпляр бота
|
||||||
|
console.log('Перед stopBot');
|
||||||
await stopBot();
|
await stopBot();
|
||||||
|
console.log('После stopBot, перед getBot');
|
||||||
|
getBot();
|
||||||
|
console.log('После getBot, перед созданием EmailBotService');
|
||||||
|
|
||||||
// Добавляем обработку ошибок при запуске бота
|
// Добавляем обработку ошибок при запуске бота
|
||||||
try {
|
try {
|
||||||
await getBot(); // getBot теперь асинхронный и сам запускает бота
|
console.log('Пробуем создать экземпляр EmailBotService');
|
||||||
console.log('Telegram bot started');
|
|
||||||
|
// Запуск email-бота
|
||||||
|
console.log('Создаём экземпляр EmailBotService');
|
||||||
|
// const emailBot = new EmailBotService();
|
||||||
|
// await emailBot.start();
|
||||||
|
|
||||||
// Добавляем graceful shutdown
|
// Добавляем graceful shutdown
|
||||||
process.once('SIGINT', async () => {
|
process.once('SIGINT', async () => {
|
||||||
@@ -53,6 +61,7 @@ async function initServices() {
|
|||||||
// Бот будет запущен при следующем перезапуске
|
// Бот будет запущен при следующем перезапуске
|
||||||
} else {
|
} else {
|
||||||
logger.error('Error launching Telegram bot:', error);
|
logger.error('Error launching Telegram bot:', error);
|
||||||
|
console.error('Ошибка при запуске Telegram-бота:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +100,13 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запуск сервера
|
// Для отладки
|
||||||
const host = app.get('host');
|
// const host = app.get('host');
|
||||||
app.listen(PORT, host, async () => {
|
// console.log('host:', host);
|
||||||
|
app.listen(PORT, async () => {
|
||||||
try {
|
try {
|
||||||
await initServices();
|
await initServices();
|
||||||
console.log(`Server is running on http://${host}:${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting server:', error);
|
console.error('Error starting server:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const verificationService = require('./verification-service'); // Использ
|
|||||||
const identityService = require('./identity-service'); // <-- ДОБАВЛЕН ИМПОРТ
|
const identityService = require('./identity-service'); // <-- ДОБАВЛЕН ИМПОРТ
|
||||||
const authTokenService = require('./authTokenService');
|
const authTokenService = require('./authTokenService');
|
||||||
const rpcProviderService = require('./rpcProviderService');
|
const rpcProviderService = require('./rpcProviderService');
|
||||||
|
const { getLinkedWallet } = require('./wallet-service');
|
||||||
|
|
||||||
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
||||||
|
|
||||||
@@ -359,26 +360,6 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение связанного кошелька
|
|
||||||
async getLinkedWallet(userId) {
|
|
||||||
logger.info(`[getLinkedWallet] Called with userId: ${userId} (Type: ${typeof userId})`);
|
|
||||||
try {
|
|
||||||
const result = await db.getQuery()(
|
|
||||||
`SELECT provider_id as address
|
|
||||||
FROM user_identities
|
|
||||||
WHERE user_id = $1 AND provider = 'wallet'`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
logger.info(`[getLinkedWallet] DB query result for userId ${userId}:`, result.rows);
|
|
||||||
const address = result.rows[0]?.address;
|
|
||||||
logger.info(`[getLinkedWallet] Returning address: ${address} for userId ${userId}`);
|
|
||||||
return address;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[getLinkedWallet] Error fetching linked wallet for userId ${userId}:`, error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет роль пользователя Telegram
|
* Проверяет роль пользователя Telegram
|
||||||
* @param {number} userId - ID пользователя
|
* @param {number} userId - ID пользователя
|
||||||
@@ -387,7 +368,7 @@ class AuthService {
|
|||||||
async checkUserRole(userId) {
|
async checkUserRole(userId) {
|
||||||
try {
|
try {
|
||||||
// Проверяем наличие связанного кошелька
|
// Проверяем наличие связанного кошелька
|
||||||
const wallet = await this.getLinkedWallet(userId);
|
const wallet = await getLinkedWallet(userId);
|
||||||
|
|
||||||
// Если кошелек не привязан, пользователь получает роль user
|
// Если кошелек не привязан, пользователь получает роль user
|
||||||
// с базовым доступом к чату и истории сообщений
|
// с базовым доступом к чату и истории сообщений
|
||||||
@@ -429,7 +410,7 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем наличие кошелька и определяем роль
|
// Проверяем наличие кошелька и определяем роль
|
||||||
const wallet = await this.getLinkedWallet(userId);
|
const wallet = await getLinkedWallet(userId);
|
||||||
let role = 'user'; // Базовая роль для доступа к чату
|
let role = 'user'; // Базовая роль для доступа к чату
|
||||||
|
|
||||||
if (wallet) {
|
if (wallet) {
|
||||||
@@ -814,7 +795,7 @@ class AuthService {
|
|||||||
|
|
||||||
// 4. Проверить роль на основе привязанного кошелька
|
// 4. Проверить роль на основе привязанного кошелька
|
||||||
try {
|
try {
|
||||||
const linkedWallet = await this.getLinkedWallet(userId);
|
const linkedWallet = await getLinkedWallet(userId);
|
||||||
if (linkedWallet && linkedWallet.provider_id) {
|
if (linkedWallet && linkedWallet.provider_id) {
|
||||||
logger.info(`[handleEmailVerification] Found linked wallet ${linkedWallet.provider_id}. Checking role...`);
|
logger.info(`[handleEmailVerification] Found linked wallet ${linkedWallet.provider_id}. Checking role...`);
|
||||||
const isAdmin = await this.checkAdminRole(linkedWallet.provider_id);
|
const isAdmin = await this.checkAdminRole(linkedWallet.provider_id);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
const verificationService = require('./verification-service');
|
const verificationService = require('./verification-service');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const EmailBotService = require('./emailBot');
|
const EmailBotService = require('./emailBot.js');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const authService = require('./auth-service');
|
const authService = require('./auth-service');
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const simpleParser = require('mailparser').simpleParser;
|
|||||||
const { processMessage } = require('./ai-assistant');
|
const { processMessage } = require('./ai-assistant');
|
||||||
const { inspect } = require('util');
|
const { inspect } = require('util');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const identityService = require('./identity-service');
|
||||||
|
const aiAssistant = require('./ai-assistant');
|
||||||
|
|
||||||
class EmailBotService {
|
class EmailBotService {
|
||||||
async getSettingsFromDb() {
|
async getSettingsFromDb() {
|
||||||
@@ -124,6 +126,49 @@ class EmailBotService {
|
|||||||
logger.error(`Error parsing message: ${err}`);
|
logger.error(`Error parsing message: ${err}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const fromEmail = parsed.from?.value?.[0]?.address;
|
||||||
|
const subject = parsed.subject || '';
|
||||||
|
const text = parsed.text || '';
|
||||||
|
const html = parsed.html || '';
|
||||||
|
// 1. Найти или создать пользователя
|
||||||
|
const { userId, role } = await identityService.findOrCreateUserWithRole('email', fromEmail);
|
||||||
|
// 2. Сохранить письмо и вложения в messages
|
||||||
|
let hasAttachments = parsed.attachments && parsed.attachments.length > 0;
|
||||||
|
if (hasAttachments) {
|
||||||
|
for (const att of parsed.attachments) {
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10, $11)`,
|
||||||
|
[userId, 'user', text, 'email', role, 'in',
|
||||||
|
att.filename,
|
||||||
|
att.contentType,
|
||||||
|
att.size,
|
||||||
|
att.content,
|
||||||
|
JSON.stringify({ subject, html })
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`,
|
||||||
|
[userId, 'user', text, 'email', role, 'in', JSON.stringify({ subject, html })]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 3. Получить ответ от ИИ
|
||||||
|
const aiResponse = await aiAssistant.getResponse(text, 'auto');
|
||||||
|
// 4. Сохранить ответ в БД
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`,
|
||||||
|
[userId, 'assistant', aiResponse, 'email', role, 'out', JSON.stringify({ subject, html })]
|
||||||
|
);
|
||||||
|
// 5. Отправить ответ на email
|
||||||
|
await this.sendEmail(fromEmail, 'Re: ' + subject, aiResponse);
|
||||||
|
} catch (processErr) {
|
||||||
|
logger.error('Error processing incoming email:', processErr);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -181,6 +226,45 @@ class EmailBotService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
logger.info('[EmailBot] start() called');
|
||||||
|
const imapConfig = await this.getImapConfig();
|
||||||
|
// Логируем IMAP-конфиг (без пароля)
|
||||||
|
const safeConfig = { ...imapConfig };
|
||||||
|
if (safeConfig.password) safeConfig.password = '***';
|
||||||
|
logger.info('[EmailBot] IMAP config:', safeConfig);
|
||||||
|
let attempt = 0;
|
||||||
|
const maxAttempts = 3;
|
||||||
|
const tryConnect = () => {
|
||||||
|
attempt++;
|
||||||
|
logger.info(`[EmailBot] IMAP connect attempt ${attempt}`);
|
||||||
|
this.imap = new Imap(imapConfig);
|
||||||
|
this.imap.once('ready', () => {
|
||||||
|
logger.info('[EmailBot] IMAP connection ready');
|
||||||
|
this.imap.openBox('INBOX', false, (err, box) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`[EmailBot] Error opening INBOX: ${err.message}`);
|
||||||
|
this.imap.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('[EmailBot] INBOX opened successfully');
|
||||||
|
});
|
||||||
|
// После успешного подключения — обычная логика
|
||||||
|
this.checkEmails();
|
||||||
|
logger.info('[EmailBot] Email bot started and IMAP connection initiated');
|
||||||
|
});
|
||||||
|
this.imap.once('error', (err) => {
|
||||||
|
logger.error(`[EmailBot] IMAP connection error: ${err.message}`);
|
||||||
|
if (err.message && err.message.toLowerCase().includes('timed out') && attempt < maxAttempts) {
|
||||||
|
logger.warn(`[EmailBot] IMAP reconnecting in 10 seconds (attempt ${attempt + 1})...`);
|
||||||
|
setTimeout(tryConnect, 10000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.imap.connect();
|
||||||
|
};
|
||||||
|
tryConnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = EmailBotService;
|
module.exports = EmailBotService;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getLinkedWallet } = require('./wallet-service');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис для работы с идентификаторами пользователей
|
* Сервис для работы с идентификаторами пользователей
|
||||||
@@ -521,6 +522,38 @@ class IdentityService {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальная функция: найти или создать пользователя по идентификатору, привязать идентификатор, проверить роль
|
||||||
|
* @param {string} provider - Тип идентификатора ('email' | 'telegram')
|
||||||
|
* @param {string} providerId - Значение идентификатора
|
||||||
|
* @param {object} [options] - Дополнительные опции
|
||||||
|
* @returns {Promise<{userId: number, role: string, isNew: boolean}>}
|
||||||
|
*/
|
||||||
|
async findOrCreateUserWithRole(provider, providerId, options = {}) {
|
||||||
|
let user = await this.findUserByIdentity(provider, providerId);
|
||||||
|
let isNew = false;
|
||||||
|
if (!user) {
|
||||||
|
// Создаем пользователя
|
||||||
|
const newUserResult = await db.getQuery()('INSERT INTO users (role) VALUES ($1) RETURNING id', ['user']);
|
||||||
|
const userId = newUserResult.rows[0].id;
|
||||||
|
await this.saveIdentity(userId, provider, providerId, true);
|
||||||
|
user = { id: userId, role: 'user' };
|
||||||
|
isNew = true;
|
||||||
|
}
|
||||||
|
// Проверяем связь с кошельком
|
||||||
|
const wallet = await getLinkedWallet(user.id);
|
||||||
|
let role = 'user';
|
||||||
|
if (wallet) {
|
||||||
|
const isAdmin = await authService.checkAdminRole(wallet);
|
||||||
|
role = isAdmin ? 'admin' : 'user';
|
||||||
|
// Обновляем роль в users, если изменилась
|
||||||
|
if (user.role !== role) {
|
||||||
|
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [role, user.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { userId: user.id, role, isNew };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new IdentityService();
|
module.exports = new IdentityService();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const db = require('../db');
|
|||||||
const authService = require('./auth-service');
|
const authService = require('./auth-service');
|
||||||
const verificationService = require('./verification-service');
|
const verificationService = require('./verification-service');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const identityService = require('./identity-service');
|
||||||
|
const aiAssistant = require('./ai-assistant');
|
||||||
|
|
||||||
let botInstance = null;
|
let botInstance = null;
|
||||||
let telegramSettingsCache = null;
|
let telegramSettingsCache = null;
|
||||||
@@ -27,227 +29,305 @@ async function getBot() {
|
|||||||
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
|
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обработка кодов верификации
|
// Универсальный обработчик текстовых сообщений
|
||||||
botInstance.on('text', async (ctx) => {
|
botInstance.on('text', async (ctx) => {
|
||||||
const code = ctx.message.text.trim();
|
const text = ctx.message.text.trim();
|
||||||
|
// 1. Если команда — пропустить
|
||||||
try {
|
if (text.startsWith('/')) return;
|
||||||
// Получаем код верификации для всех активных кодов с провайдером telegram
|
// 2. Проверка: это потенциальный код?
|
||||||
const codeResult = await db.getQuery()(
|
const isPotentialCode = (str) => /^[A-Z0-9]{6}$/i.test(str);
|
||||||
`SELECT * FROM verification_codes
|
if (isPotentialCode(text)) {
|
||||||
WHERE code = $1
|
|
||||||
AND provider = 'telegram'
|
|
||||||
AND used = false
|
|
||||||
AND expires_at > NOW()`,
|
|
||||||
[code]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (codeResult.rows.length === 0) {
|
|
||||||
ctx.reply('Неверный код подтверждения');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const verification = codeResult.rows[0];
|
|
||||||
const providerId = verification.provider_id;
|
|
||||||
const linkedUserId = verification.user_id; // Получаем связанный userId если он есть
|
|
||||||
let userId;
|
|
||||||
let userRole = 'user'; // Роль по умолчанию
|
|
||||||
|
|
||||||
// Отмечаем код как использованный
|
|
||||||
await db.getQuery()('UPDATE verification_codes SET used = true WHERE id = $1', [
|
|
||||||
verification.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.info('Starting Telegram auth process for code:', code);
|
|
||||||
|
|
||||||
// Проверяем, существует ли уже пользователь с таким Telegram ID
|
|
||||||
const existingTelegramUser = await db.getQuery()(
|
|
||||||
`SELECT ui.user_id
|
|
||||||
FROM user_identities ui
|
|
||||||
WHERE ui.provider = 'telegram' AND ui.provider_id = $1`,
|
|
||||||
[ctx.from.id.toString()]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingTelegramUser.rows.length > 0) {
|
|
||||||
// Если пользователь с таким Telegram ID уже существует, используем его
|
|
||||||
userId = existingTelegramUser.rows[0].user_id;
|
|
||||||
logger.info(`Using existing user ${userId} for Telegram account ${ctx.from.id}`);
|
|
||||||
} else {
|
|
||||||
// Если код верификации был связан с существующим пользователем, используем его
|
|
||||||
if (linkedUserId) {
|
|
||||||
// Используем userId из кода верификации
|
|
||||||
userId = linkedUserId;
|
|
||||||
// Связываем Telegram с этим пользователем
|
|
||||||
await db.getQuery()(
|
|
||||||
`INSERT INTO user_identities
|
|
||||||
(user_id, provider, provider_id, created_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())`,
|
|
||||||
[userId, 'telegram', ctx.from.id.toString()]
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`Linked Telegram account ${ctx.from.id} to pre-authenticated user ${userId}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Проверяем, есть ли пользователь, связанный с гостевым идентификатором
|
|
||||||
let existingUserWithGuestId = null;
|
|
||||||
if (providerId) {
|
|
||||||
const guestUserResult = await db.getQuery()(
|
|
||||||
`SELECT user_id FROM guest_user_mapping WHERE guest_id = $1`,
|
|
||||||
[providerId]
|
|
||||||
);
|
|
||||||
if (guestUserResult.rows.length > 0) {
|
|
||||||
existingUserWithGuestId = guestUserResult.rows[0].user_id;
|
|
||||||
logger.info(
|
|
||||||
`Found existing user ${existingUserWithGuestId} by guest ID ${providerId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUserWithGuestId) {
|
|
||||||
// Используем существующего пользователя и добавляем ему Telegram идентификатор
|
|
||||||
userId = existingUserWithGuestId;
|
|
||||||
await db.getQuery()(
|
|
||||||
`INSERT INTO user_identities
|
|
||||||
(user_id, provider, provider_id, created_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())`,
|
|
||||||
[userId, 'telegram', ctx.from.id.toString()]
|
|
||||||
);
|
|
||||||
logger.info(`Linked Telegram account ${ctx.from.id} to existing user ${userId}`);
|
|
||||||
} else {
|
|
||||||
// Создаем нового пользователя, если не нашли существующего
|
|
||||||
const userResult = await db.getQuery()(
|
|
||||||
'INSERT INTO users (created_at, role) VALUES (NOW(), $1) RETURNING id',
|
|
||||||
['user']
|
|
||||||
);
|
|
||||||
userId = userResult.rows[0].id;
|
|
||||||
|
|
||||||
// Связываем Telegram с новым пользователем
|
|
||||||
await db.getQuery()(
|
|
||||||
`INSERT INTO user_identities
|
|
||||||
(user_id, provider, provider_id, created_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())`,
|
|
||||||
[userId, 'telegram', ctx.from.id.toString()]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Если был гостевой ID, связываем его с новым пользователем
|
|
||||||
if (providerId) {
|
|
||||||
await db.getQuery()(
|
|
||||||
`INSERT INTO guest_user_mapping
|
|
||||||
(user_id, guest_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (guest_id) DO UPDATE SET user_id = $1`,
|
|
||||||
[userId, providerId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Created new user ${userId} with Telegram account ${ctx.from.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----> НАЧАЛО: Проверка роли на основе привязанного кошелька <----
|
|
||||||
if (userId) { // Убедимся, что userId определен
|
|
||||||
logger.info(`[TelegramBot] Checking linked wallet for determined userId: ${userId} (Type: ${typeof userId})`);
|
|
||||||
try {
|
|
||||||
const linkedWallet = await authService.getLinkedWallet(userId);
|
|
||||||
if (linkedWallet) {
|
|
||||||
logger.info(`[TelegramBot] Found linked wallet ${linkedWallet} for user ${userId}. Checking role...`);
|
|
||||||
const isAdmin = await authService.checkAdminRole(linkedWallet);
|
|
||||||
userRole = isAdmin ? 'admin' : 'user';
|
|
||||||
logger.info(`[TelegramBot] Role for user ${userId} determined as: ${userRole}`);
|
|
||||||
|
|
||||||
// Опционально: Обновить роль в таблице users
|
|
||||||
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
|
||||||
if (currentUser.rows.length > 0 && currentUser.rows[0].role !== userRole) {
|
|
||||||
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [userRole, userId]);
|
|
||||||
logger.info(`[TelegramBot] Updated user role in DB to ${userRole}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info(`[TelegramBot] No linked wallet found for user ${userId}. Checking current DB role.`);
|
|
||||||
// Если кошелька нет, берем текущую роль из базы
|
|
||||||
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
|
||||||
if (currentUser.rows.length > 0) {
|
|
||||||
userRole = currentUser.rows[0].role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (roleCheckError) {
|
|
||||||
logger.error(`[TelegramBot] Error checking admin role for user ${userId}:`, roleCheckError);
|
|
||||||
// В случае ошибки берем роль из базы или оставляем 'user'
|
|
||||||
try {
|
|
||||||
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
|
||||||
if (currentUser.rows.length > 0) { userRole = currentUser.rows[0].role; }
|
|
||||||
} catch (dbError) { /* ignore */ }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error('[TelegramBot] Cannot check role because userId is undefined!');
|
|
||||||
}
|
|
||||||
// ----> КОНЕЦ: Проверка роли <----
|
|
||||||
|
|
||||||
// Логируем userId перед обновлением сессии
|
|
||||||
logger.info(`[telegramBot] Attempting to update session for userId: ${userId}`);
|
|
||||||
|
|
||||||
// Находим последнюю активную сессию для данного userId
|
|
||||||
let activeSessionId = null;
|
|
||||||
try {
|
try {
|
||||||
// Ищем сессию, где есть userId и она не истекла (проверка expires_at)
|
// Получаем код верификации для всех активных кодов с провайдером telegram
|
||||||
// Сортируем по expires_at DESC чтобы взять самую "свежую", если их несколько
|
const codeResult = await db.getQuery()(
|
||||||
const sessionResult = await db.getQuery()(
|
`SELECT * FROM verification_codes
|
||||||
`SELECT sid FROM session
|
WHERE code = $1
|
||||||
WHERE sess ->> 'userId' = $1
|
AND provider = 'telegram'
|
||||||
AND expire > NOW()
|
AND used = false
|
||||||
ORDER BY expire DESC
|
AND expires_at > NOW()`,
|
||||||
LIMIT 1`,
|
[text.toUpperCase()]
|
||||||
[userId?.toString()] // Используем optional chaining и преобразуем в строку
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sessionResult.rows.length > 0) {
|
|
||||||
activeSessionId = sessionResult.rows[0].sid;
|
|
||||||
logger.info(`[telegramBot] Found active session ID ${activeSessionId} for user ${userId}`);
|
|
||||||
|
|
||||||
// Обновляем найденную сессию в базе данных, добавляя/перезаписывая данные Telegram
|
if (codeResult.rows.length === 0) {
|
||||||
const updateResult = await db.getQuery()(
|
ctx.reply('Неверный код подтверждения');
|
||||||
`UPDATE session
|
return;
|
||||||
SET sess = (sess::jsonb || $1::jsonb)::json
|
}
|
||||||
WHERE sid = $2`,
|
|
||||||
[
|
const verification = codeResult.rows[0];
|
||||||
JSON.stringify({
|
const providerId = verification.provider_id;
|
||||||
// authenticated: true, // Не перезаписываем, т.к. сессия уже должна быть аутентифицирована
|
const linkedUserId = verification.user_id; // Получаем связанный userId если он есть
|
||||||
authType: 'telegram', // Обновляем тип аутентификации
|
let userId;
|
||||||
telegramId: ctx.from.id.toString(),
|
let userRole = 'user'; // Роль по умолчанию
|
||||||
telegramUsername: ctx.from.username,
|
|
||||||
telegramFirstName: ctx.from.first_name,
|
// Отмечаем код как использованный
|
||||||
role: userRole, // Записываем определенную роль
|
await db.getQuery()('UPDATE verification_codes SET used = true WHERE id = $1', [
|
||||||
// userId: userId?.toString() // userId уже должен быть в сессии
|
verification.id,
|
||||||
}),
|
]);
|
||||||
activeSessionId // Обновляем по найденному session ID
|
|
||||||
]
|
logger.info('Starting Telegram auth process for code:', text);
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже пользователь с таким Telegram ID
|
||||||
|
const existingTelegramUser = await db.getQuery()(
|
||||||
|
`SELECT ui.user_id
|
||||||
|
FROM user_identities ui
|
||||||
|
WHERE ui.provider = 'telegram' AND ui.provider_id = $1`,
|
||||||
|
[ctx.from.id.toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingTelegramUser.rows.length > 0) {
|
||||||
|
// Если пользователь с таким Telegram ID уже существует, используем его
|
||||||
|
userId = existingTelegramUser.rows[0].user_id;
|
||||||
|
logger.info(`Using existing user ${userId} for Telegram account ${ctx.from.id}`);
|
||||||
|
} else {
|
||||||
|
// Если код верификации был связан с существующим пользователем, используем его
|
||||||
|
if (linkedUserId) {
|
||||||
|
// Используем userId из кода верификации
|
||||||
|
userId = linkedUserId;
|
||||||
|
// Связываем Telegram с этим пользователем
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO user_identities
|
||||||
|
(user_id, provider, provider_id, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
|
[userId, 'telegram', ctx.from.id.toString()]
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Linked Telegram account ${ctx.from.id} to pre-authenticated user ${userId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Проверяем, есть ли пользователь, связанный с гостевым идентификатором
|
||||||
|
let existingUserWithGuestId = null;
|
||||||
|
if (providerId) {
|
||||||
|
const guestUserResult = await db.getQuery()(
|
||||||
|
`SELECT user_id FROM guest_user_mapping WHERE guest_id = $1`,
|
||||||
|
[providerId]
|
||||||
|
);
|
||||||
|
if (guestUserResult.rows.length > 0) {
|
||||||
|
existingUserWithGuestId = guestUserResult.rows[0].user_id;
|
||||||
|
logger.info(
|
||||||
|
`Found existing user ${existingUserWithGuestId} by guest ID ${providerId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUserWithGuestId) {
|
||||||
|
// Используем существующего пользователя и добавляем ему Telegram идентификатор
|
||||||
|
userId = existingUserWithGuestId;
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO user_identities
|
||||||
|
(user_id, provider, provider_id, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
|
[userId, 'telegram', ctx.from.id.toString()]
|
||||||
|
);
|
||||||
|
logger.info(`Linked Telegram account ${ctx.from.id} to existing user ${userId}`);
|
||||||
|
} else {
|
||||||
|
// Создаем нового пользователя, если не нашли существующего
|
||||||
|
const userResult = await db.getQuery()(
|
||||||
|
'INSERT INTO users (created_at, role) VALUES (NOW(), $1) RETURNING id',
|
||||||
|
['user']
|
||||||
|
);
|
||||||
|
userId = userResult.rows[0].id;
|
||||||
|
|
||||||
|
// Связываем Telegram с новым пользователем
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO user_identities
|
||||||
|
(user_id, provider, provider_id, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
|
[userId, 'telegram', ctx.from.id.toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если был гостевой ID, связываем его с новым пользователем
|
||||||
|
if (providerId) {
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO guest_user_mapping
|
||||||
|
(user_id, guest_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guest_id) DO UPDATE SET user_id = $1`,
|
||||||
|
[userId, providerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Created new user ${userId} with Telegram account ${ctx.from.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----> НАЧАЛО: Проверка роли на основе привязанного кошелька <----
|
||||||
|
if (userId) { // Убедимся, что userId определен
|
||||||
|
logger.info(`[TelegramBot] Checking linked wallet for determined userId: ${userId} (Type: ${typeof userId})`);
|
||||||
|
try {
|
||||||
|
const linkedWallet = await authService.getLinkedWallet(userId);
|
||||||
|
if (linkedWallet) {
|
||||||
|
logger.info(`[TelegramBot] Found linked wallet ${linkedWallet} for user ${userId}. Checking role...`);
|
||||||
|
const isAdmin = await authService.checkAdminRole(linkedWallet);
|
||||||
|
userRole = isAdmin ? 'admin' : 'user';
|
||||||
|
logger.info(`[TelegramBot] Role for user ${userId} determined as: ${userRole}`);
|
||||||
|
|
||||||
|
// Опционально: Обновить роль в таблице users
|
||||||
|
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
||||||
|
if (currentUser.rows.length > 0 && currentUser.rows[0].role !== userRole) {
|
||||||
|
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [userRole, userId]);
|
||||||
|
logger.info(`[TelegramBot] Updated user role in DB to ${userRole}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`[TelegramBot] No linked wallet found for user ${userId}. Checking current DB role.`);
|
||||||
|
// Если кошелька нет, берем текущую роль из базы
|
||||||
|
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
||||||
|
if (currentUser.rows.length > 0) {
|
||||||
|
userRole = currentUser.rows[0].role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (roleCheckError) {
|
||||||
|
logger.error(`[TelegramBot] Error checking admin role for user ${userId}:`, roleCheckError);
|
||||||
|
// В случае ошибки берем роль из базы или оставляем 'user'
|
||||||
|
try {
|
||||||
|
const currentUser = await db.getQuery()('SELECT role FROM users WHERE id = $1', [userId]);
|
||||||
|
if (currentUser.rows.length > 0) { userRole = currentUser.rows[0].role; }
|
||||||
|
} catch (dbError) { /* ignore */ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('[TelegramBot] Cannot check role because userId is undefined!');
|
||||||
|
}
|
||||||
|
// ----> КОНЕЦ: Проверка роли <----
|
||||||
|
|
||||||
|
// Логируем userId перед обновлением сессии
|
||||||
|
logger.info(`[telegramBot] Attempting to update session for userId: ${userId}`);
|
||||||
|
|
||||||
|
// Находим последнюю активную сессию для данного userId
|
||||||
|
let activeSessionId = null;
|
||||||
|
try {
|
||||||
|
// Ищем сессию, где есть userId и она не истекла (проверка expires_at)
|
||||||
|
// Сортируем по expires_at DESC чтобы взять самую "свежую", если их несколько
|
||||||
|
const sessionResult = await db.getQuery()(
|
||||||
|
`SELECT sid FROM session
|
||||||
|
WHERE sess ->> 'userId' = $1
|
||||||
|
AND expire > NOW()
|
||||||
|
ORDER BY expire DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId?.toString()] // Используем optional chaining и преобразуем в строку
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updateResult.rowCount > 0) {
|
if (sessionResult.rows.length > 0) {
|
||||||
logger.info(`[telegramBot] Session ${activeSessionId} updated successfully with Telegram data for user ${userId}`);
|
activeSessionId = sessionResult.rows[0].sid;
|
||||||
|
logger.info(`[telegramBot] Found active session ID ${activeSessionId} for user ${userId}`);
|
||||||
|
|
||||||
|
// Обновляем найденную сессию в базе данных, добавляя/перезаписывая данные Telegram
|
||||||
|
const updateResult = await db.getQuery()(
|
||||||
|
`UPDATE session
|
||||||
|
SET sess = (sess::jsonb || $1::jsonb)::json
|
||||||
|
WHERE sid = $2`,
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
// authenticated: true, // Не перезаписываем, т.к. сессия уже должна быть аутентифицирована
|
||||||
|
authType: 'telegram', // Обновляем тип аутентификации
|
||||||
|
telegramId: ctx.from.id.toString(),
|
||||||
|
telegramUsername: ctx.from.username,
|
||||||
|
telegramFirstName: ctx.from.first_name,
|
||||||
|
role: userRole, // Записываем определенную роль
|
||||||
|
// userId: userId?.toString() // userId уже должен быть в сессии
|
||||||
|
}),
|
||||||
|
activeSessionId // Обновляем по найденному session ID
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.rowCount > 0) {
|
||||||
|
logger.info(`[telegramBot] Session ${activeSessionId} updated successfully with Telegram data for user ${userId}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[telegramBot] Session update query executed but did not update rows for sid: ${activeSessionId}. This might indicate a concurrency issue or incorrect sid.`);
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`[telegramBot] Session update query executed but did not update rows for sid: ${activeSessionId}. This might indicate a concurrency issue or incorrect sid.`);
|
logger.warn(`[telegramBot] No active web session found for userId: ${userId}. Telegram is linked, but the user might need to refresh their browser session.`);
|
||||||
}
|
}
|
||||||
|
} catch(sessionError) {
|
||||||
} else {
|
logger.error(`[telegramBot] Error finding or updating session for userId ${userId}:`, sessionError);
|
||||||
logger.warn(`[telegramBot] No active web session found for userId: ${userId}. Telegram is linked, but the user might need to refresh their browser session.`);
|
|
||||||
}
|
}
|
||||||
} catch(sessionError) {
|
|
||||||
logger.error(`[telegramBot] Error finding or updating session for userId ${userId}:`, sessionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем сообщение об успешной аутентификации
|
// Отправляем сообщение об успешной аутентификации
|
||||||
await ctx.reply('Аутентификация успешна! Можете вернуться в приложение.');
|
await ctx.reply('Аутентификация успешна! Можете вернуться в приложение.');
|
||||||
|
|
||||||
// Удаляем сообщение с кодом
|
// Удаляем сообщение с кодом
|
||||||
try {
|
try {
|
||||||
await ctx.deleteMessage(ctx.message.message_id);
|
await ctx.deleteMessage(ctx.message.message_id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Could not delete code message:', error);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Could not delete code message:', error);
|
logger.error('Error in Telegram auth:', error);
|
||||||
|
await ctx.reply('Произошла ошибка при аутентификации. Попробуйте позже.');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 3. Всё остальное — чат с ИИ-ассистентом
|
||||||
|
try {
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
// 1. Найти или создать пользователя
|
||||||
|
const { userId, role } = await identityService.findOrCreateUserWithRole('telegram', telegramId);
|
||||||
|
|
||||||
|
// 2. Сохранить входящее сообщение в messages
|
||||||
|
let content = text;
|
||||||
|
let attachmentMeta = {};
|
||||||
|
// Проверяем вложения (фото, документ, аудио, видео)
|
||||||
|
let fileId, fileName, mimeType, fileSize, attachmentBuffer;
|
||||||
|
if (ctx.message.document) {
|
||||||
|
fileId = ctx.message.document.file_id;
|
||||||
|
fileName = ctx.message.document.file_name;
|
||||||
|
mimeType = ctx.message.document.mime_type;
|
||||||
|
fileSize = ctx.message.document.file_size;
|
||||||
|
} else if (ctx.message.photo && ctx.message.photo.length > 0) {
|
||||||
|
// Берём самое большое фото
|
||||||
|
const photo = ctx.message.photo[ctx.message.photo.length - 1];
|
||||||
|
fileId = photo.file_id;
|
||||||
|
fileName = 'photo.jpg';
|
||||||
|
mimeType = 'image/jpeg';
|
||||||
|
fileSize = photo.file_size;
|
||||||
|
} else if (ctx.message.audio) {
|
||||||
|
fileId = ctx.message.audio.file_id;
|
||||||
|
fileName = ctx.message.audio.file_name || 'audio.ogg';
|
||||||
|
mimeType = ctx.message.audio.mime_type || 'audio/ogg';
|
||||||
|
fileSize = ctx.message.audio.file_size;
|
||||||
|
} else if (ctx.message.video) {
|
||||||
|
fileId = ctx.message.video.file_id;
|
||||||
|
fileName = ctx.message.video.file_name || 'video.mp4';
|
||||||
|
mimeType = ctx.message.video.mime_type || 'video/mp4';
|
||||||
|
fileSize = ctx.message.video.file_size;
|
||||||
|
}
|
||||||
|
if (fileId) {
|
||||||
|
// Скачиваем файл
|
||||||
|
const fileLink = await ctx.telegram.getFileLink(fileId);
|
||||||
|
const res = await fetch(fileLink.href);
|
||||||
|
attachmentBuffer = await res.buffer();
|
||||||
|
attachmentMeta = {
|
||||||
|
attachment_filename: fileName,
|
||||||
|
attachment_mimetype: mimeType,
|
||||||
|
attachment_size: fileSize,
|
||||||
|
attachment_data: attachmentBuffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Сохраняем сообщение в БД
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10)`,
|
||||||
|
[userId, 'user', content, 'telegram', role, 'in',
|
||||||
|
attachmentMeta.attachment_filename || null,
|
||||||
|
attachmentMeta.attachment_mimetype || null,
|
||||||
|
attachmentMeta.attachment_size || null,
|
||||||
|
attachmentMeta.attachment_data || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Получить ответ от ИИ
|
||||||
|
const aiResponse = await aiAssistant.getResponse(content, 'auto');
|
||||||
|
// 4. Сохранить ответ в БД
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
|
||||||
|
[userId, 'assistant', aiResponse, 'telegram', role, 'out']
|
||||||
|
);
|
||||||
|
// 5. Отправить ответ пользователю
|
||||||
|
await ctx.reply(aiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in Telegram auth:', error);
|
logger.error('[TelegramBot] Ошибка при обработке сообщения:', error);
|
||||||
await ctx.reply('Произошла ошибка при аутентификации. Попробуйте позже.');
|
await ctx.reply('Произошла ошибка при обработке вашего сообщения. Попробуйте позже.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
24
backend/services/wallet-service.js
Normal file
24
backend/services/wallet-service.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const db = require('../db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Получение связанного кошелька
|
||||||
|
async function getLinkedWallet(userId) {
|
||||||
|
logger.info(`[getLinkedWallet] Called with userId: ${userId} (Type: ${typeof userId})`);
|
||||||
|
try {
|
||||||
|
const result = await db.getQuery()(
|
||||||
|
`SELECT provider_id as address
|
||||||
|
FROM user_identities
|
||||||
|
WHERE user_id = $1 AND provider = 'wallet'`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info(`[getLinkedWallet] DB query result for userId ${userId}:`, result.rows);
|
||||||
|
const address = result.rows[0]?.address;
|
||||||
|
logger.info(`[getLinkedWallet] Returning address: ${address} for userId ${userId}`);
|
||||||
|
return address;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[getLinkedWallet] Error fetching linked wallet for userId ${userId}:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getLinkedWallet };
|
||||||
122
frontend/src/components/ContactTable.vue
Normal file
122
frontend/src/components/ContactTable.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contact-table-modal">
|
||||||
|
<div class="contact-table-header">
|
||||||
|
<h2>Контакты</h2>
|
||||||
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<table class="contact-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telegram</th>
|
||||||
|
<th>Кошелек</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="contact in contacts" :key="contact.id">
|
||||||
|
<td>{{ contact.name || '-' }}</td>
|
||||||
|
<td>{{ contact.email || '-' }}</td>
|
||||||
|
<td>{{ contact.telegram || '-' }}</td>
|
||||||
|
<td>{{ contact.wallet || '-' }}</td>
|
||||||
|
<td>{{ formatDate(contact.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
const props = defineProps({
|
||||||
|
contacts: { type: Array, required: true }
|
||||||
|
});
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return '-';
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contact-table-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||||
|
padding: 32px 24px 24px 24px;
|
||||||
|
max-width: 950px;
|
||||||
|
margin: 40px auto;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.contact-table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.contact-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.contact-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f5f7fa;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.contact-table tbody tr {
|
||||||
|
transition: background 0.18s;
|
||||||
|
}
|
||||||
|
.contact-table tbody tr:nth-child(even) {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.contact-table tbody tr:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
.contact-table td {
|
||||||
|
padding: 12px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
vertical-align: middle;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.contact-table th:first-child, .contact-table td:first-child {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
}
|
||||||
|
.contact-table th:last-child, .contact-table td:last-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.contact-table-modal {
|
||||||
|
padding: 12px 2px;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
.contact-table th, .contact-table td {
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.contact-table-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
623
frontend/src/components/DleManagement.vue
Normal file
623
frontend/src/components/DleManagement.vue
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dle-management-modal">
|
||||||
|
<div class="dle-management-header">
|
||||||
|
<h2>Ваши DLE</h2>
|
||||||
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dle-list-section">
|
||||||
|
<div v-if="dleList.length === 0" class="no-dle-message">
|
||||||
|
<p>У вас пока нет созданных DLE.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="dle-list">
|
||||||
|
<div v-for="(dle, index) in dleList" :key="index" class="dle-card"
|
||||||
|
:class="{ 'active': selectedDleIndex === index }"
|
||||||
|
@click="selectDle(index)">
|
||||||
|
<h3>{{ dle.name }} ({{ dle.symbol }})</h3>
|
||||||
|
<p><strong>Адрес:</strong> {{ shortenAddress(dle.tokenAddress) }}</p>
|
||||||
|
<p><strong>Местонахождение:</strong> {{ dle.location }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedDle" class="dle-details-section">
|
||||||
|
<h2>Управление "{{ selectedDle.name }}"</h2>
|
||||||
|
<div class="dle-tabs">
|
||||||
|
<div class="tab-header">
|
||||||
|
<div class="tab-button" :class="{ 'active': activeTab === 'info' }" @click="activeTab = 'info'">
|
||||||
|
<i class="fas fa-info-circle"></i> Основная информация
|
||||||
|
</div>
|
||||||
|
<div class="tab-button" :class="{ 'active': activeTab === 'proposals' }" @click="activeTab = 'proposals'">
|
||||||
|
<i class="fas fa-tasks"></i> Предложения
|
||||||
|
</div>
|
||||||
|
<div class="tab-button" :class="{ 'active': activeTab === 'governance' }" @click="activeTab = 'governance'">
|
||||||
|
<i class="fas fa-balance-scale"></i> Управление
|
||||||
|
</div>
|
||||||
|
<div class="tab-button" :class="{ 'active': activeTab === 'modules' }" @click="activeTab = 'modules'">
|
||||||
|
<i class="fas fa-puzzle-piece"></i> Модули
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" v-if="activeTab === 'info'">
|
||||||
|
<div class="info-card">
|
||||||
|
<h3>Основная информация</h3>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Название:</span>
|
||||||
|
<span class="info-value">{{ selectedDle.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Символ токена:</span>
|
||||||
|
<span class="info-value">{{ selectedDle.symbol }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Местонахождение:</span>
|
||||||
|
<span class="info-value">{{ selectedDle.location }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Коды деятельности:</span>
|
||||||
|
<span class="info-value">{{ selectedDle.isicCodes && selectedDle.isicCodes.length ? selectedDle.isicCodes.join(', ') : 'Не указаны' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Дата создания:</span>
|
||||||
|
<span class="info-value">{{ formatDate(selectedDle.creationTimestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contract-cards">
|
||||||
|
<div class="contract-card">
|
||||||
|
<h4>Токен управления</h4>
|
||||||
|
<p class="address">{{ selectedDle.tokenAddress }}</p>
|
||||||
|
<div class="contract-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.tokenAddress)">
|
||||||
|
<i class="fas fa-copy"></i> Копировать адрес
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.tokenAddress)">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Обзор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contract-card">
|
||||||
|
<h4>Таймлок</h4>
|
||||||
|
<p class="address">{{ selectedDle.timelockAddress }}</p>
|
||||||
|
<div class="contract-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.timelockAddress)">
|
||||||
|
<i class="fas fa-copy"></i> Копировать адрес
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.timelockAddress)">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Обзор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contract-card">
|
||||||
|
<h4>Governor</h4>
|
||||||
|
<p class="address">{{ selectedDle.governorAddress }}</p>
|
||||||
|
<div class="contract-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.governorAddress)">
|
||||||
|
<i class="fas fa-copy"></i> Копировать адрес
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.governorAddress)">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Обзор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" v-if="activeTab === 'proposals'">
|
||||||
|
<h3>Предложения</h3>
|
||||||
|
<div class="proposals-actions">
|
||||||
|
<button class="btn btn-primary" @click="showCreateProposalForm = true">
|
||||||
|
<i class="fas fa-plus"></i> Создать предложение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCreateProposalForm" class="create-proposal-form">
|
||||||
|
<h4>Новое предложение</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="proposalTitle">Заголовок:</label>
|
||||||
|
<input type="text" id="proposalTitle" v-model="newProposal.title" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="proposalDescription">Описание:</label>
|
||||||
|
<textarea id="proposalDescription" v-model="newProposal.description" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-success" @click="createProposal" :disabled="isCreatingProposal">
|
||||||
|
<i class="fas fa-paper-plane"></i> {{ isCreatingProposal ? 'Отправка...' : 'Отправить' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="showCreateProposalForm = false">
|
||||||
|
<i class="fas fa-times"></i> Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proposals-list">
|
||||||
|
<p v-if="proposals.length === 0">Предложений пока нет</p>
|
||||||
|
<div v-else v-for="(proposal, index) in proposals" :key="index" class="proposal-card">
|
||||||
|
<h4>{{ proposal.title }}</h4>
|
||||||
|
<p>{{ proposal.description }}</p>
|
||||||
|
<div class="proposal-status" :class="proposal.status">
|
||||||
|
{{ getProposalStatusText(proposal.status) }}
|
||||||
|
</div>
|
||||||
|
<div class="proposal-actions">
|
||||||
|
<button class="btn btn-sm btn-primary" @click="voteForProposal(proposal.id, true)" :disabled="!canVote(proposal)">
|
||||||
|
<i class="fas fa-thumbs-up"></i> За
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" @click="voteForProposal(proposal.id, false)" :disabled="!canVote(proposal)">
|
||||||
|
<i class="fas fa-thumbs-down"></i> Против
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" v-if="activeTab === 'governance'">
|
||||||
|
<h3>Управление</h3>
|
||||||
|
<div class="governance-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<h4>Настройки Governor</h4>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Порог предложения:</span>
|
||||||
|
<span class="info-value">100,000 GT</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Кворум:</span>
|
||||||
|
<span class="info-value">4%</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Задержка голосования:</span>
|
||||||
|
<span class="info-value">1 день</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Период голосования:</span>
|
||||||
|
<span class="info-value">7 дней</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<h4>Статистика голосований</h4>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Всего предложений:</span>
|
||||||
|
<span class="info-value">{{ proposals.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Активных предложений:</span>
|
||||||
|
<span class="info-value">{{ getProposalsByStatus('active').length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Успешных предложений:</span>
|
||||||
|
<span class="info-value">{{ getProposalsByStatus('succeeded').length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Отклоненных предложений:</span>
|
||||||
|
<span class="info-value">{{ getProposalsByStatus('defeated').length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" v-if="activeTab === 'modules'">
|
||||||
|
<h3>Подключение модулей</h3>
|
||||||
|
<p>Здесь вы можете подключить дополнительные модули к вашему DLE.</p>
|
||||||
|
<div class="modules-list">
|
||||||
|
<div v-for="(module, index) in availableModules" :key="index" class="module-card">
|
||||||
|
<h4>{{ module.name }}</h4>
|
||||||
|
<p>{{ module.description }}</p>
|
||||||
|
<div class="module-status" :class="{ 'installed': module.installed }">
|
||||||
|
{{ module.installed ? 'Установлен' : 'Доступен' }}
|
||||||
|
</div>
|
||||||
|
<div class="module-actions">
|
||||||
|
<button v-if="!module.installed" class="btn btn-success" @click="installModule(module)">
|
||||||
|
<i class="fas fa-plus"></i> Установить
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn btn-danger" @click="uninstallModule(module)">
|
||||||
|
<i class="fas fa-trash"></i> Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineProps, computed } from 'vue';
|
||||||
|
const props = defineProps({
|
||||||
|
dleList: { type: Array, required: true },
|
||||||
|
selectedDleIndex: { type: Number, default: null }
|
||||||
|
});
|
||||||
|
const selectedDleIndex = ref(props.selectedDleIndex ?? 0);
|
||||||
|
const activeTab = ref('info');
|
||||||
|
const showCreateProposalForm = ref(false);
|
||||||
|
const newProposal = ref({ title: '', description: '' });
|
||||||
|
const isCreatingProposal = ref(false);
|
||||||
|
const proposals = ref([]);
|
||||||
|
const availableModules = ref([
|
||||||
|
{
|
||||||
|
name: 'Контракт на активы',
|
||||||
|
description: 'Позволяет токенизировать физические активы и управлять ими через DLE.',
|
||||||
|
installed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Мультиподпись',
|
||||||
|
description: 'Добавляет функциональность мультиподписи для повышенной безопасности.',
|
||||||
|
installed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Дивиденды',
|
||||||
|
description: 'Позволяет распределять дивиденды между держателями токенов.',
|
||||||
|
installed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Стейкинг',
|
||||||
|
description: 'Добавляет возможность стейкинга токенов для получения наград.',
|
||||||
|
installed: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const selectedDle = computed(() => {
|
||||||
|
if (selectedDleIndex.value !== null && props.dleList.length > selectedDleIndex.value) {
|
||||||
|
return props.dleList[selectedDleIndex.value];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
function selectDle(index) {
|
||||||
|
selectedDleIndex.value = index;
|
||||||
|
activeTab.value = 'info';
|
||||||
|
}
|
||||||
|
function shortenAddress(address) {
|
||||||
|
if (!address) return '';
|
||||||
|
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||||
|
}
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
alert('Адрес скопирован в буфер обмена');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Ошибка при копировании текста: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function viewOnExplorer(address) {
|
||||||
|
window.open(`https://sepolia.etherscan.io/address/${address}`, '_blank');
|
||||||
|
}
|
||||||
|
function createProposal() {
|
||||||
|
if (!newProposal.value.title || !newProposal.value.description) {
|
||||||
|
alert('Пожалуйста, заполните все поля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCreatingProposal.value = true;
|
||||||
|
try {
|
||||||
|
proposals.value.push({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: newProposal.value.title,
|
||||||
|
description: newProposal.value.description,
|
||||||
|
status: 'pending',
|
||||||
|
votes: { for: 0, against: 0 }
|
||||||
|
});
|
||||||
|
showCreateProposalForm.value = false;
|
||||||
|
newProposal.value = { title: '', description: '' };
|
||||||
|
alert('Предложение создано!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании предложения:', error);
|
||||||
|
alert('Ошибка при создании предложения');
|
||||||
|
} finally {
|
||||||
|
isCreatingProposal.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function voteForProposal(proposalId, isFor) {
|
||||||
|
try {
|
||||||
|
const proposal = proposals.value.find(p => p.id === proposalId);
|
||||||
|
if (proposal) {
|
||||||
|
if (isFor) {
|
||||||
|
proposal.votes.for += 1;
|
||||||
|
} else {
|
||||||
|
proposal.votes.against += 1;
|
||||||
|
}
|
||||||
|
if (proposal.votes.for > proposal.votes.against && proposal.votes.for >= 3) {
|
||||||
|
proposal.status = 'succeeded';
|
||||||
|
} else if (proposal.votes.against > proposal.votes.for && proposal.votes.against >= 3) {
|
||||||
|
proposal.status = 'defeated';
|
||||||
|
} else {
|
||||||
|
proposal.status = 'active';
|
||||||
|
}
|
||||||
|
alert('Ваш голос учтен!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при голосовании:', error);
|
||||||
|
alert('Ошибка при голосовании');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function canVote(proposal) {
|
||||||
|
return proposal.status === 'active' || proposal.status === 'pending';
|
||||||
|
}
|
||||||
|
function getProposalStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'pending': 'Ожидает',
|
||||||
|
'active': 'Активно',
|
||||||
|
'succeeded': 'Принято',
|
||||||
|
'defeated': 'Отклонено',
|
||||||
|
'executed': 'Выполнено'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
function getProposalsByStatus(status) {
|
||||||
|
return proposals.value.filter(p => p.status === status);
|
||||||
|
}
|
||||||
|
function installModule(module) {
|
||||||
|
module.installed = true;
|
||||||
|
alert(`Модуль "${module.name}" успешно установлен!`);
|
||||||
|
}
|
||||||
|
function uninstallModule(module) {
|
||||||
|
module.installed = false;
|
||||||
|
alert(`Модуль "${module.name}" удален.`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dle-management-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||||
|
padding: 32px 24px 24px 24px;
|
||||||
|
max-width: 950px;
|
||||||
|
margin: 40px auto;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.dle-management-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.dle-list-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.dle-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.dle-card {
|
||||||
|
width: 300px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||||
|
background: #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.dle-card.active {
|
||||||
|
border-color: #17a2b8;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.dle-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
.dle-details-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.no-dle-message {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.dle-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.tab-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-bottom 0.2s;
|
||||||
|
}
|
||||||
|
.tab-button.active {
|
||||||
|
border-bottom: 2px solid #17a2b8;
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.contract-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.contract-card {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 340px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.07);
|
||||||
|
padding: 22px 20px 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.contract-card:hover {
|
||||||
|
box-shadow: 0 6px 24px rgba(23,162,184,0.13);
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
}
|
||||||
|
.contract-card h4 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #17a2b8;
|
||||||
|
}
|
||||||
|
.contract-card .address {
|
||||||
|
font-family: 'Fira Mono', 'Consolas', monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.contract-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.contract-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.18s, color 0.18s;
|
||||||
|
}
|
||||||
|
.contract-actions .btn-secondary {
|
||||||
|
background: #f1f3f6;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.contract-actions .btn-secondary:hover {
|
||||||
|
background: #e2e6ea;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.contract-actions .btn-info {
|
||||||
|
background: #17a2b8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.contract-actions .btn-info:hover {
|
||||||
|
background: #148a9d;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.contract-cards {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.contract-card {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.proposals-actions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.create-proposal-form {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.proposals-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.proposal-card {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.proposal-status {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.proposal-status.pending {
|
||||||
|
background-color: #ffd700;
|
||||||
|
}
|
||||||
|
.proposal-status.active {
|
||||||
|
background-color: #17a2b8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.proposal-status.succeeded {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.proposal-status.defeated {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.proposal-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.modules-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.module-card {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.module-status {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.module-status.installed {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.module-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
frontend/src/services/contactsService.js
Normal file
11
frontend/src/services/contactsService.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import api from '../api/axios';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async getContacts() {
|
||||||
|
const res = await api.get('/api/users');
|
||||||
|
if (res.data && res.data.success) {
|
||||||
|
return res.data.contacts;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,274 +7,35 @@
|
|||||||
@auth-action-completed="$emit('auth-action-completed')"
|
@auth-action-completed="$emit('auth-action-completed')"
|
||||||
>
|
>
|
||||||
<div class="crm-view-container">
|
<div class="crm-view-container">
|
||||||
<h1>Управление DLE</h1>
|
<div class="dle-management-block">
|
||||||
<div v-if="isLoading">
|
<h2>Управление DLE</h2>
|
||||||
<p>Загрузка данных DLE...</p>
|
<button class="btn btn-info" @click="showDleManagement = true">
|
||||||
<div class="loading-spinner"></div>
|
<i class="fas fa-cogs"></i> Подробнее
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!auth.isAuthenticated.value">
|
<DleManagement v-if="showDleManagement" :dle-list="dleList" :selected-dle-index="selectedDleIndex" @close="showDleManagement = false" />
|
||||||
<p>Для доступа к управлению DLE необходимо <button @click="goToHomeAndShowSidebar">войти</button>.</p>
|
<div class="crm-contacts-block">
|
||||||
</div>
|
<h2>Контакты</h2>
|
||||||
<div v-else>
|
<button class="btn btn-info" @click="showContacts = true">
|
||||||
<!-- Секция со списком DLE -->
|
<i class="fas fa-address-book"></i> Подробнее
|
||||||
<div class="dle-list-section">
|
</button>
|
||||||
<h2>Ваши DLE</h2>
|
|
||||||
<div v-if="dleList.length === 0" class="no-dle-message">
|
|
||||||
<p>У вас пока нет созданных DLE.</p>
|
|
||||||
<button @click="goToBlockchainSettings" class="btn btn-primary">
|
|
||||||
<i class="fas fa-plus"></i> Создать новое DLE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="dle-list">
|
|
||||||
<div v-for="(dle, index) in dleList" :key="index" class="dle-card"
|
|
||||||
:class="{ 'active': selectedDleIndex === index }"
|
|
||||||
@click="selectDle(index)">
|
|
||||||
<h3>{{ dle.name }} ({{ dle.symbol }})</h3>
|
|
||||||
<p><strong>Адрес:</strong> {{ shortenAddress(dle.tokenAddress) }}</p>
|
|
||||||
<p><strong>Местонахождение:</strong> {{ dle.location }}</p>
|
|
||||||
<div class="dle-card-actions">
|
|
||||||
<button class="btn btn-sm btn-info">
|
|
||||||
<i class="fas fa-info-circle"></i> Подробнее
|
|
||||||
</button>
|
|
||||||
<button v-if="!dle.name || !dle.name.trim() || !dle.tokenAddress" class="btn btn-sm btn-danger" @click.stop="deleteDLE(index, dle)">
|
|
||||||
<i class="fas fa-trash"></i> Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Секция с деталями выбранного DLE -->
|
|
||||||
<div v-if="selectedDle" class="dle-details-section">
|
|
||||||
<h2>Управление "{{ selectedDle.name }}"</h2>
|
|
||||||
|
|
||||||
<div class="dle-tabs">
|
|
||||||
<div class="tab-header">
|
|
||||||
<div class="tab-button"
|
|
||||||
:class="{ 'active': activeTab === 'info' }"
|
|
||||||
@click="activeTab = 'info'">
|
|
||||||
<i class="fas fa-info-circle"></i> Основная информация
|
|
||||||
</div>
|
|
||||||
<div class="tab-button"
|
|
||||||
:class="{ 'active': activeTab === 'proposals' }"
|
|
||||||
@click="activeTab = 'proposals'">
|
|
||||||
<i class="fas fa-tasks"></i> Предложения
|
|
||||||
</div>
|
|
||||||
<div class="tab-button"
|
|
||||||
:class="{ 'active': activeTab === 'governance' }"
|
|
||||||
@click="activeTab = 'governance'">
|
|
||||||
<i class="fas fa-balance-scale"></i> Управление
|
|
||||||
</div>
|
|
||||||
<div class="tab-button"
|
|
||||||
:class="{ 'active': activeTab === 'modules' }"
|
|
||||||
@click="activeTab = 'modules'">
|
|
||||||
<i class="fas fa-puzzle-piece"></i> Модули
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Вкладка информации -->
|
|
||||||
<div class="tab-content" v-if="activeTab === 'info'">
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>Основная информация</h3>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Название:</span>
|
|
||||||
<span class="info-value">{{ selectedDle.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Символ токена:</span>
|
|
||||||
<span class="info-value">{{ selectedDle.symbol }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Местонахождение:</span>
|
|
||||||
<span class="info-value">{{ selectedDle.location }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Коды деятельности:</span>
|
|
||||||
<span class="info-value">{{ selectedDle.isicCodes && selectedDle.isicCodes.length ? selectedDle.isicCodes.join(', ') : 'Не указаны' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Дата создания:</span>
|
|
||||||
<span class="info-value">{{ formatDate(selectedDle.creationTimestamp) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contract-cards">
|
|
||||||
<div class="contract-card">
|
|
||||||
<h4>Токен управления</h4>
|
|
||||||
<p class="address">{{ selectedDle.tokenAddress }}</p>
|
|
||||||
<div class="contract-actions">
|
|
||||||
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.tokenAddress)">
|
|
||||||
<i class="fas fa-copy"></i> Копировать адрес
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.tokenAddress)">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Обзор
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contract-card">
|
|
||||||
<h4>Таймлок</h4>
|
|
||||||
<p class="address">{{ selectedDle.timelockAddress }}</p>
|
|
||||||
<div class="contract-actions">
|
|
||||||
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.timelockAddress)">
|
|
||||||
<i class="fas fa-copy"></i> Копировать адрес
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.timelockAddress)">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Обзор
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contract-card">
|
|
||||||
<h4>Governor</h4>
|
|
||||||
<p class="address">{{ selectedDle.governorAddress }}</p>
|
|
||||||
<div class="contract-actions">
|
|
||||||
<button class="btn btn-sm btn-secondary" @click="copyToClipboard(selectedDle.governorAddress)">
|
|
||||||
<i class="fas fa-copy"></i> Копировать адрес
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-info" @click="viewOnExplorer(selectedDle.governorAddress)">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Обзор
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Вкладка предложений -->
|
|
||||||
<div class="tab-content" v-if="activeTab === 'proposals'">
|
|
||||||
<h3>Предложения</h3>
|
|
||||||
<div class="proposals-actions">
|
|
||||||
<button class="btn btn-primary" @click="showCreateProposalForm = true">
|
|
||||||
<i class="fas fa-plus"></i> Создать предложение
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showCreateProposalForm" class="create-proposal-form">
|
|
||||||
<h4>Новое предложение</h4>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="proposalTitle">Заголовок:</label>
|
|
||||||
<input type="text" id="proposalTitle" v-model="newProposal.title" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="proposalDescription">Описание:</label>
|
|
||||||
<textarea id="proposalDescription" v-model="newProposal.description" class="form-control" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn-success" @click="createProposal" :disabled="isCreatingProposal">
|
|
||||||
<i class="fas fa-paper-plane"></i> {{ isCreatingProposal ? 'Отправка...' : 'Отправить' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" @click="showCreateProposalForm = false">
|
|
||||||
<i class="fas fa-times"></i> Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="proposals-list">
|
|
||||||
<p v-if="proposals.length === 0">Предложений пока нет</p>
|
|
||||||
<div v-else v-for="(proposal, index) in proposals" :key="index" class="proposal-card">
|
|
||||||
<h4>{{ proposal.title }}</h4>
|
|
||||||
<p>{{ proposal.description }}</p>
|
|
||||||
<div class="proposal-status" :class="proposal.status">
|
|
||||||
{{ getProposalStatusText(proposal.status) }}
|
|
||||||
</div>
|
|
||||||
<div class="proposal-actions">
|
|
||||||
<button class="btn btn-sm btn-primary" @click="voteForProposal(proposal.id, true)" :disabled="!canVote(proposal)">
|
|
||||||
<i class="fas fa-thumbs-up"></i> За
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" @click="voteForProposal(proposal.id, false)" :disabled="!canVote(proposal)">
|
|
||||||
<i class="fas fa-thumbs-down"></i> Против
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Вкладка управления -->
|
|
||||||
<div class="tab-content" v-if="activeTab === 'governance'">
|
|
||||||
<h3>Управление</h3>
|
|
||||||
<div class="governance-info">
|
|
||||||
<div class="info-card">
|
|
||||||
<h4>Настройки Governor</h4>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Порог предложения:</span>
|
|
||||||
<span class="info-value">100,000 GT</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Кворум:</span>
|
|
||||||
<span class="info-value">4%</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Задержка голосования:</span>
|
|
||||||
<span class="info-value">1 день</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Период голосования:</span>
|
|
||||||
<span class="info-value">7 дней</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h4>Статистика голосований</h4>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Всего предложений:</span>
|
|
||||||
<span class="info-value">{{ proposals.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Активных предложений:</span>
|
|
||||||
<span class="info-value">{{ getProposalsByStatus('active').length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Успешных предложений:</span>
|
|
||||||
<span class="info-value">{{ getProposalsByStatus('succeeded').length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Отклоненных предложений:</span>
|
|
||||||
<span class="info-value">{{ getProposalsByStatus('defeated').length }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Вкладка модулей -->
|
|
||||||
<div class="tab-content" v-if="activeTab === 'modules'">
|
|
||||||
<h3>Подключение модулей</h3>
|
|
||||||
<p>Здесь вы можете подключить дополнительные модули к вашему DLE.</p>
|
|
||||||
|
|
||||||
<div class="modules-list">
|
|
||||||
<div v-for="(module, index) in availableModules" :key="index" class="module-card">
|
|
||||||
<h4>{{ module.name }}</h4>
|
|
||||||
<p>{{ module.description }}</p>
|
|
||||||
<div class="module-status" :class="{ 'installed': module.installed }">
|
|
||||||
{{ module.installed ? 'Установлен' : 'Доступен' }}
|
|
||||||
</div>
|
|
||||||
<div class="module-actions">
|
|
||||||
<button v-if="!module.installed" class="btn btn-success" @click="installModule(module)">
|
|
||||||
<i class="fas fa-plus"></i> Установить
|
|
||||||
</button>
|
|
||||||
<button v-else class="btn btn-danger" @click="uninstallModule(module)">
|
|
||||||
<i class="fas fa-trash"></i> Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ContactTable v-if="showContacts" :contacts="contacts" @close="showContacts = false" />
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits, computed } from 'vue';
|
import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits, computed, watch } from 'vue';
|
||||||
import { useAuth } from '../composables/useAuth';
|
import { useAuth } from '../composables/useAuth';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { setToStorage } from '../utils/storage';
|
import { setToStorage } from '../utils/storage';
|
||||||
import BaseLayout from '../components/BaseLayout.vue';
|
import BaseLayout from '../components/BaseLayout.vue';
|
||||||
import eventBus from '../utils/eventBus';
|
import eventBus from '../utils/eventBus';
|
||||||
import dleService from '../services/dleService';
|
import dleService from '../services/dleService';
|
||||||
|
import ContactTable from '../components/ContactTable.vue';
|
||||||
|
import contactsService from '../services/contactsService.js';
|
||||||
|
import DleManagement from '../components/DleManagement.vue';
|
||||||
|
|
||||||
// Определяем props
|
// Определяем props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -292,46 +53,11 @@ const router = useRouter();
|
|||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const dleList = ref([]);
|
const dleList = ref([]);
|
||||||
const selectedDleIndex = ref(null);
|
const selectedDleIndex = ref(null);
|
||||||
const activeTab = ref('info');
|
|
||||||
|
|
||||||
// Для создания предложений
|
const showDleManagement = ref(false);
|
||||||
const showCreateProposalForm = ref(false);
|
const showContacts = ref(false);
|
||||||
const newProposal = ref({ title: '', description: '' });
|
const contacts = ref([]);
|
||||||
const isCreatingProposal = ref(false);
|
const isLoadingContacts = ref(false);
|
||||||
|
|
||||||
// Список доступных модулей
|
|
||||||
const availableModules = ref([
|
|
||||||
{
|
|
||||||
name: 'Контракт на активы',
|
|
||||||
description: 'Позволяет токенизировать физические активы и управлять ими через DLE.',
|
|
||||||
installed: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Мультиподпись',
|
|
||||||
description: 'Добавляет функциональность мультиподписи для повышенной безопасности.',
|
|
||||||
installed: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Дивиденды',
|
|
||||||
description: 'Позволяет распределять дивиденды между держателями токенов.',
|
|
||||||
installed: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Стейкинг',
|
|
||||||
description: 'Добавляет возможность стейкинга токенов для получения наград.',
|
|
||||||
installed: false
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Список предложений (в реальном приложении будет загружаться из смарт-контракта)
|
|
||||||
const proposals = ref([]);
|
|
||||||
|
|
||||||
const selectedDle = computed(() => {
|
|
||||||
if (selectedDleIndex.value !== null && dleList.value.length > selectedDleIndex.value) {
|
|
||||||
return dleList.value[selectedDleIndex.value];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Функция для перехода на домашнюю страницу и открытия боковой панели
|
// Функция для перехода на домашнюю страницу и открытия боковой панели
|
||||||
const goToHomeAndShowSidebar = () => {
|
const goToHomeAndShowSidebar = () => {
|
||||||
@@ -344,139 +70,6 @@ const goToBlockchainSettings = () => {
|
|||||||
router.push({ name: 'settings-blockchain' });
|
router.push({ name: 'settings-blockchain' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для выбора DLE
|
|
||||||
const selectDle = (index) => {
|
|
||||||
selectedDleIndex.value = index;
|
|
||||||
activeTab.value = 'info'; // При выборе нового DLE сбрасываем на вкладку информации
|
|
||||||
};
|
|
||||||
|
|
||||||
// Форматирование адреса (сокращение)
|
|
||||||
const shortenAddress = (address) => {
|
|
||||||
if (!address) return '';
|
|
||||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Форматирование даты из timestamp
|
|
||||||
const formatDate = (timestamp) => {
|
|
||||||
if (!timestamp) return 'N/A';
|
|
||||||
return new Date(timestamp * 1000).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Копирование в буфер обмена
|
|
||||||
const copyToClipboard = (text) => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
.then(() => {
|
|
||||||
alert('Адрес скопирован в буфер обмена');
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Ошибка при копировании текста: ', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Открытие адреса в обозревателе блокчейна
|
|
||||||
const viewOnExplorer = (address) => {
|
|
||||||
// Используем Sepolia Etherscan как пример
|
|
||||||
window.open(`https://sepolia.etherscan.io/address/${address}`, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Создание нового предложения
|
|
||||||
const createProposal = async () => {
|
|
||||||
if (!newProposal.value.title || !newProposal.value.description) {
|
|
||||||
alert('Пожалуйста, заполните все поля');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreatingProposal.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// В реальном приложении здесь будет вызов смарт-контракта
|
|
||||||
// Пока просто добавляем в локальный массив
|
|
||||||
proposals.value.push({
|
|
||||||
id: Date.now().toString(),
|
|
||||||
title: newProposal.value.title,
|
|
||||||
description: newProposal.value.description,
|
|
||||||
status: 'pending',
|
|
||||||
votes: { for: 0, against: 0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
showCreateProposalForm.value = false;
|
|
||||||
newProposal.value = { title: '', description: '' };
|
|
||||||
|
|
||||||
alert('Предложение создано!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при создании предложения:', error);
|
|
||||||
alert('Ошибка при создании предложения');
|
|
||||||
} finally {
|
|
||||||
isCreatingProposal.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Голосование за предложение
|
|
||||||
const voteForProposal = async (proposalId, isFor) => {
|
|
||||||
try {
|
|
||||||
// В реальном приложении здесь будет вызов смарт-контракта
|
|
||||||
// Пока просто обновляем локальный массив
|
|
||||||
const proposal = proposals.value.find(p => p.id === proposalId);
|
|
||||||
if (proposal) {
|
|
||||||
if (isFor) {
|
|
||||||
proposal.votes.for += 1;
|
|
||||||
} else {
|
|
||||||
proposal.votes.against += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем статус в зависимости от голосов
|
|
||||||
if (proposal.votes.for > proposal.votes.against && proposal.votes.for >= 3) {
|
|
||||||
proposal.status = 'succeeded';
|
|
||||||
} else if (proposal.votes.against > proposal.votes.for && proposal.votes.against >= 3) {
|
|
||||||
proposal.status = 'defeated';
|
|
||||||
} else {
|
|
||||||
proposal.status = 'active';
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Ваш голос учтен!');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при голосовании:', error);
|
|
||||||
alert('Ошибка при голосовании');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Проверка возможности голосования
|
|
||||||
const canVote = (proposal) => {
|
|
||||||
return proposal.status === 'active' || proposal.status === 'pending';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Получение текстового статуса предложения
|
|
||||||
const getProposalStatusText = (status) => {
|
|
||||||
const statusMap = {
|
|
||||||
'pending': 'Ожидает',
|
|
||||||
'active': 'Активно',
|
|
||||||
'succeeded': 'Принято',
|
|
||||||
'defeated': 'Отклонено',
|
|
||||||
'executed': 'Выполнено'
|
|
||||||
};
|
|
||||||
return statusMap[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Фильтрация предложений по статусу
|
|
||||||
const getProposalsByStatus = (status) => {
|
|
||||||
return proposals.value.filter(p => p.status === status);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Установка модуля
|
|
||||||
const installModule = (module) => {
|
|
||||||
// В реальном приложении здесь будет вызов смарт-контракта
|
|
||||||
module.installed = true;
|
|
||||||
alert(`Модуль "${module.name}" успешно установлен!`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Удаление модуля
|
|
||||||
const uninstallModule = (module) => {
|
|
||||||
// В реальном приложении здесь будет вызов смарт-контракта
|
|
||||||
module.installed = false;
|
|
||||||
alert(`Модуль "${module.name}" удален.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Загрузка списка DLE
|
// Загрузка списка DLE
|
||||||
const loadDLEs = async () => {
|
const loadDLEs = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
@@ -527,41 +120,21 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция для удаления DLE
|
async function loadContacts() {
|
||||||
const deleteDLE = async (index, dle) => {
|
isLoadingContacts.value = true;
|
||||||
if (!confirm(`Вы уверены, что хотите удалить DLE "${dle.name || 'без имени'}"?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (dle.tokenAddress) {
|
contacts.value = await contactsService.getContacts();
|
||||||
// Если есть адрес токена, удаляем через основной метод
|
} catch (e) {
|
||||||
await dleService.deleteDLE(dle.tokenAddress);
|
contacts.value = [];
|
||||||
} else if (dle._fileName) {
|
alert('Ошибка загрузки контактов');
|
||||||
// Если нет адреса токена, но есть имя файла, удаляем как пустое DLE
|
} finally {
|
||||||
await dleService.deleteEmptyDLE(dle._fileName);
|
isLoadingContacts.value = false;
|
||||||
} else {
|
|
||||||
// Если нет ни адреса токена, ни имени файла, просто удаляем из списка
|
|
||||||
console.warn('DLE не имеет ни адреса токена, ни имени файла. Удаляется только из локального списка.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем из локального списка
|
|
||||||
dleList.value.splice(index, 1);
|
|
||||||
|
|
||||||
// Если был выбран этот DLE, сбрасываем выбор
|
|
||||||
if (selectedDleIndex.value === index) {
|
|
||||||
selectedDleIndex.value = null;
|
|
||||||
} else if (selectedDleIndex.value > index) {
|
|
||||||
// Если был выбран DLE с большим индексом, корректируем индекс
|
|
||||||
selectedDleIndex.value--;
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(`DLE успешно удалено`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при удалении DLE:', error);
|
|
||||||
alert(`Ошибка при удалении DLE: ${error.message || 'Неизвестная ошибка'}`);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
watch(showContacts, (val) => {
|
||||||
|
if (val) loadContacts();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -655,294 +228,43 @@ strong {
|
|||||||
border-color: #17a2b8;
|
border-color: #17a2b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Стили для секции списка DLE */
|
.dle-management-block {
|
||||||
.dle-list-section {
|
margin: 32px 0 24px 0;
|
||||||
margin-bottom: 30px;
|
padding: 24px;
|
||||||
}
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
.dle-list {
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 15px;
|
justify-content: space-between;
|
||||||
margin-top: 20px;
|
|
||||||
}
|
}
|
||||||
|
.dle-management-block h2 {
|
||||||
.dle-card {
|
margin: 0;
|
||||||
width: 300px;
|
font-size: 1.4rem;
|
||||||
padding: 15px;
|
font-weight: 600;
|
||||||
border: 1px solid var(--color-grey-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
.dle-management-block .btn {
|
||||||
.dle-card:hover {
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dle-card.active {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dle-card h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dle-card-actions {
|
|
||||||
margin-top: 15px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для секции деталей DLE */
|
|
||||||
.dle-details-section {
|
|
||||||
margin-top: 30px;
|
|
||||||
border-top: 1px solid var(--color-grey-light);
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для вкладок */
|
|
||||||
.dle-tabs {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-header {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--color-grey-light);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
border-bottom-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для информационных карточек */
|
|
||||||
.info-card {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-weight: 500;
|
|
||||||
width: 200px;
|
|
||||||
color: var(--color-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для карточек контрактов */
|
|
||||||
.contract-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-card {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 250px;
|
|
||||||
padding: 15px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-card h4 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-card .address {
|
|
||||||
font-family: monospace;
|
|
||||||
word-break: break-all;
|
|
||||||
padding: 5px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contract-actions {
|
|
||||||
margin-top: 15px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для формы создания предложения */
|
|
||||||
.create-proposal-form {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
padding: 8px 18px;
|
||||||
color: #495057;
|
|
||||||
background-color: #fff;
|
|
||||||
background-clip: padding-box;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.crm-contacts-block {
|
||||||
|
margin: 32px 0 24px 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
margin-top: 20px;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
.crm-contacts-block h2 {
|
||||||
/* Стили для списка предложений */
|
margin: 0;
|
||||||
.proposal-card {
|
font-size: 1.4rem;
|
||||||
background-color: #f8f9fa;
|
font-weight: 600;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
|
.crm-contacts-block .btn {
|
||||||
.proposal-status {
|
font-size: 1rem;
|
||||||
display: inline-block;
|
padding: 8px 18px;
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 15px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status.pending {
|
|
||||||
background-color: #ffeeba;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status.active {
|
|
||||||
background-color: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status.succeeded {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status.defeated {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status.executed {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для секции модулей */
|
|
||||||
.modules-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-card {
|
|
||||||
width: 300px;
|
|
||||||
padding: 15px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-status.installed {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-actions {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для случая отсутствия DLE */
|
|
||||||
.no-dle-message {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-dle-message p {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для секции управления */
|
|
||||||
.governance-info {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.governance-info .info-card {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 250px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user