ваше сообщение коммита
This commit is contained in:
105
SETUP_ACCESS_LEVELS.md
Normal file
105
SETUP_ACCESS_LEVELS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Настройка расширенной системы прав доступа
|
||||||
|
|
||||||
|
## Описание изменений
|
||||||
|
|
||||||
|
Добавлена расширенная система прав доступа с настраиваемыми порогами:
|
||||||
|
|
||||||
|
- **Read-Only (1+ токен)** - только просмотр данных
|
||||||
|
- **Editor (2+ токен)** - просмотр + редактирование + удаление
|
||||||
|
- **User (0 токенов)** - базовые права без изменений
|
||||||
|
|
||||||
|
## Новые поля в форме добавления токенов
|
||||||
|
|
||||||
|
На странице `/settings/security` в форме добавления токенов добавлены два новых поля:
|
||||||
|
|
||||||
|
1. **Минимум токенов для Read-Only доступа** (по умолчанию: 1)
|
||||||
|
2. **Минимум токенов для Editor доступа** (по умолчанию: 2)
|
||||||
|
|
||||||
|
## Применение изменений
|
||||||
|
|
||||||
|
### 1. Обновление базы данных
|
||||||
|
|
||||||
|
Выполните SQL скрипт для добавления новых полей:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Подключитесь к базе данных PostgreSQL
|
||||||
|
psql -h localhost -U your_username -d your_database
|
||||||
|
|
||||||
|
# Выполните миграцию
|
||||||
|
\i backend/scripts/add_access_thresholds.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Перезапуск сервера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В папке backend
|
||||||
|
yarn restart
|
||||||
|
# или
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Перезапуск frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В папке frontend
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как использовать
|
||||||
|
|
||||||
|
### Для администраторов:
|
||||||
|
|
||||||
|
1. Перейдите на страницу `/settings/security`
|
||||||
|
2. В разделе "Токены аутентификации" нажмите "Подробнее"
|
||||||
|
3. При добавлении нового токена заполните:
|
||||||
|
- Название токена
|
||||||
|
- Адрес смарт-контракта
|
||||||
|
- Сеть блокчейна
|
||||||
|
- Минимальный баланс
|
||||||
|
- **Минимум токенов для Read-Only доступа**
|
||||||
|
- **Минимум токенов для Editor доступа**
|
||||||
|
|
||||||
|
### Для пользователей:
|
||||||
|
|
||||||
|
- Система автоматически определяет уровень доступа на основе количества токенов
|
||||||
|
- Отображается текущий уровень доступа с визуальными индикаторами
|
||||||
|
- UI автоматически адаптируется под уровень доступа
|
||||||
|
|
||||||
|
## Примеры настроек
|
||||||
|
|
||||||
|
### Стандартные настройки:
|
||||||
|
- Read-Only: 1 токен
|
||||||
|
- Editor: 2 токена
|
||||||
|
|
||||||
|
### Строгие настройки:
|
||||||
|
- Read-Only: 2 токена
|
||||||
|
- Editor: 5 токенов
|
||||||
|
|
||||||
|
### Либеральные настройки:
|
||||||
|
- Read-Only: 1 токен
|
||||||
|
- Editor: 1 токен (все пользователи с токенами могут редактировать)
|
||||||
|
|
||||||
|
## Технические детали
|
||||||
|
|
||||||
|
### Backend изменения:
|
||||||
|
- `auth-service.js` - добавлена функция `getUserAccessLevel()`
|
||||||
|
- `authTokenService.js` - поддержка новых полей
|
||||||
|
- `tokenBalanceService.js` - возврат порогов доступа
|
||||||
|
- `routes/settings.js` - API endpoint для новых полей
|
||||||
|
- `routes/auth.js` - новый endpoint `/access-level/:address`
|
||||||
|
|
||||||
|
### Frontend изменения:
|
||||||
|
- `useAuth.js` - поддержка уровней доступа
|
||||||
|
- `usePermissions.js` - новый composable для проверки прав
|
||||||
|
- `AuthTokensSettings.vue` - форма с новыми полями
|
||||||
|
- `SecuritySettingsView.vue` - использование новых прав доступа
|
||||||
|
|
||||||
|
### Новые поля в БД:
|
||||||
|
- `auth_tokens.readonly_threshold` - порог для Read-Only
|
||||||
|
- `auth_tokens.editor_threshold` - порог для Editor
|
||||||
|
|
||||||
|
## Обратная совместимость
|
||||||
|
|
||||||
|
- Все существующие токены получают значения по умолчанию (1 и 2)
|
||||||
|
- Старые проверки `isAdmin` продолжают работать
|
||||||
|
- Система автоматически мигрирует существующие данные
|
||||||
@@ -946,4 +946,25 @@ router.get('/check-tokens/:address', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Маршрут для получения уровня доступа пользователя
|
||||||
|
router.get('/access-level/:address', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { address } = req.params;
|
||||||
|
|
||||||
|
// Получаем уровень доступа пользователя
|
||||||
|
const accessLevel = await authService.getUserAccessLevel(address);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: accessLevel,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting user access level:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -21,20 +21,28 @@ const { requireAuth } = require('../middleware/auth');
|
|||||||
// Проверка статуса подключения к Ollama
|
// Проверка статуса подключения к Ollama
|
||||||
router.get('/status', requireAuth, async (req, res) => {
|
router.get('/status', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Проверяем, что контейнер Ollama запущен
|
const axios = require('axios');
|
||||||
const { stdout } = await execAsync('docker ps --filter "name=dapp-ollama" --format "{{.Names}}"');
|
const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
|
||||||
const isContainerRunning = stdout.trim() === 'dapp-ollama';
|
|
||||||
|
|
||||||
if (!isContainerRunning) {
|
// Проверяем API Ollama через HTTP запрос
|
||||||
return res.json({ connected: false, error: 'Ollama container not running' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем API Ollama
|
|
||||||
try {
|
try {
|
||||||
const { stdout: apiResponse } = await execAsync('docker exec dapp-ollama ollama list');
|
const response = await axios.get(`${ollamaUrl}/api/tags`, {
|
||||||
return res.json({ connected: true, message: 'Ollama is running' });
|
timeout: 5000 // 5 секунд таймаут
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = response.data.models || [];
|
||||||
|
return res.json({
|
||||||
|
connected: true,
|
||||||
|
message: 'Ollama is running',
|
||||||
|
models: models.length,
|
||||||
|
availableModels: models.map(m => m.name)
|
||||||
|
});
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
return res.json({ connected: false, error: 'Ollama API not responding' });
|
logger.error('Ollama API error:', apiError.message);
|
||||||
|
return res.json({
|
||||||
|
connected: false,
|
||||||
|
error: `Ollama API not responding: ${apiError.message}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking Ollama status:', error);
|
logger.error('Error checking Ollama status:', error);
|
||||||
@@ -45,21 +53,19 @@ router.get('/status', requireAuth, async (req, res) => {
|
|||||||
// Получение списка установленных моделей
|
// Получение списка установленных моделей
|
||||||
router.get('/models', requireAuth, async (req, res) => {
|
router.get('/models', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('docker exec dapp-ollama ollama list');
|
const axios = require('axios');
|
||||||
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок
|
const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
|
||||||
|
|
||||||
const models = lines.map(line => {
|
const response = await axios.get(`${ollamaUrl}/api/tags`, {
|
||||||
const parts = line.trim().split(/\s+/);
|
timeout: 5000
|
||||||
if (parts.length >= 4) {
|
});
|
||||||
return {
|
|
||||||
name: parts[0],
|
const models = (response.data.models || []).map(model => ({
|
||||||
id: parts[1],
|
name: model.name,
|
||||||
size: parseInt(parts[2]) || 0,
|
id: model.model || model.name,
|
||||||
modified: parts.slice(3).join(' ')
|
size: model.size || 0,
|
||||||
};
|
modified: model.modified_at || 'Unknown'
|
||||||
}
|
}));
|
||||||
return null;
|
|
||||||
}).filter(model => model !== null);
|
|
||||||
|
|
||||||
res.json({ models });
|
res.json({ models });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ router.get('/auth-tokens', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokensResult = await db.getQuery()(
|
const tokensResult = await db.getQuery()(
|
||||||
'SELECT id, min_balance, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
||||||
[encryptionKey]
|
[encryptionKey]
|
||||||
);
|
);
|
||||||
const authTokens = tokensResult.rows;
|
const authTokens = tokensResult.rows;
|
||||||
@@ -218,11 +218,11 @@ router.post('/auth-tokens', requireAdmin, async (req, res, next) => {
|
|||||||
// Добавление/обновление одного токена
|
// Добавление/обновление одного токена
|
||||||
router.post('/auth-token', requireAdmin, async (req, res, next) => {
|
router.post('/auth-token', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, address, network, minBalance } = req.body;
|
const { name, address, network, minBalance, readonlyThreshold, editorThreshold } = req.body;
|
||||||
if (!name || !address || !network) {
|
if (!name || !address || !network) {
|
||||||
return res.status(400).json({ success: false, error: 'name, address и network обязательны' });
|
return res.status(400).json({ success: false, error: 'name, address и network обязательны' });
|
||||||
}
|
}
|
||||||
await authTokenService.upsertAuthToken({ name, address, network, minBalance });
|
await authTokenService.upsertAuthToken({ name, address, network, minBalance, readonlyThreshold, editorThreshold });
|
||||||
|
|
||||||
// Отправляем WebSocket уведомление о добавлении токена
|
// Отправляем WebSocket уведомление о добавлении токена
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ async function upsertProviderSettings({ provider, api_key, base_url, selected_mo
|
|||||||
const existing = await encryptedDb.getData(TABLE, { provider: provider }, 1);
|
const existing = await encryptedDb.getData(TABLE, { provider: provider }, 1);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
// Обновляем существующую запись
|
// Обновляем существующую запись по ID
|
||||||
return await encryptedDb.saveData(TABLE, data, { provider: provider });
|
return await encryptedDb.saveData(TABLE, data, { id: existing[0].id });
|
||||||
} else {
|
} else {
|
||||||
// Создаем новую запись
|
// Создаем новую запись
|
||||||
return await encryptedDb.saveData(TABLE, data);
|
return await encryptedDb.saveData(TABLE, data);
|
||||||
@@ -120,29 +120,51 @@ async function getAllLLMModels() {
|
|||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
if (provider.selected_model) {
|
if (provider.selected_model) {
|
||||||
|
// Фильтруем embedding модели - они не должны быть в списке LLM
|
||||||
|
const modelName = provider.selected_model.toLowerCase();
|
||||||
|
const isEmbeddingModel = modelName.includes('embed') ||
|
||||||
|
modelName.includes('embedding') ||
|
||||||
|
modelName.includes('bge') ||
|
||||||
|
modelName.includes('nomic') ||
|
||||||
|
modelName.includes('text-embedding') ||
|
||||||
|
modelName.includes('mxbai') ||
|
||||||
|
modelName.includes('sentence') ||
|
||||||
|
modelName.includes('ada-002') ||
|
||||||
|
modelName.includes('text-embedding-ada') ||
|
||||||
|
modelName.includes('text-embedding-3');
|
||||||
|
|
||||||
|
if (!isEmbeddingModel) {
|
||||||
allModels.push({
|
allModels.push({
|
||||||
id: provider.selected_model,
|
id: provider.selected_model,
|
||||||
provider: provider.provider
|
provider: provider.provider
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Для Ollama проверяем реально установленные модели
|
// Для Ollama проверяем реально установленные модели через HTTP API
|
||||||
try {
|
try {
|
||||||
const { exec } = require('child_process');
|
const axios = require('axios');
|
||||||
const util = require('util');
|
const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
|
||||||
const execAsync = util.promisify(exec);
|
|
||||||
|
|
||||||
// Проверяем, какие модели установлены в Ollama
|
const response = await axios.get(`${ollamaUrl}/api/tags`, {
|
||||||
const { stdout } = await execAsync('docker exec dapp-ollama ollama list');
|
timeout: 5000
|
||||||
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок
|
});
|
||||||
|
|
||||||
for (const line of lines) {
|
const models = response.data.models || [];
|
||||||
const parts = line.trim().split(/\s+/);
|
for (const model of models) {
|
||||||
if (parts.length >= 2) {
|
// Фильтруем embedding модели из Ollama
|
||||||
const modelName = parts[0];
|
const modelName = model.name.toLowerCase();
|
||||||
|
const isEmbeddingModel = modelName.includes('embed') ||
|
||||||
|
modelName.includes('embedding') ||
|
||||||
|
modelName.includes('bge') ||
|
||||||
|
modelName.includes('nomic') ||
|
||||||
|
modelName.includes('mxbai') ||
|
||||||
|
modelName.includes('sentence');
|
||||||
|
|
||||||
|
if (!isEmbeddingModel) {
|
||||||
allModels.push({
|
allModels.push({
|
||||||
id: modelName,
|
id: model.name,
|
||||||
provider: 'ollama'
|
provider: 'ollama'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -189,29 +211,25 @@ async function getAllEmbeddingModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для Ollama проверяем реально установленные embedding модели
|
// Для Ollama проверяем реально установленные embedding модели через HTTP API
|
||||||
try {
|
try {
|
||||||
const { exec } = require('child_process');
|
const axios = require('axios');
|
||||||
const util = require('util');
|
const ollamaUrl = process.env.OLLAMA_BASE_URL || 'http://ollama:11434';
|
||||||
const execAsync = util.promisify(exec);
|
|
||||||
|
|
||||||
// Проверяем, какие embedding модели установлены в Ollama
|
const response = await axios.get(`${ollamaUrl}/api/tags`, {
|
||||||
const { stdout } = await execAsync('docker exec dapp-ollama ollama list');
|
timeout: 5000
|
||||||
const lines = stdout.trim().split('\n').slice(1); // Пропускаем заголовок
|
});
|
||||||
|
|
||||||
for (const line of lines) {
|
const models = response.data.models || [];
|
||||||
const parts = line.trim().split(/\s+/);
|
for (const model of models) {
|
||||||
if (parts.length >= 2) {
|
|
||||||
const modelName = parts[0];
|
|
||||||
// Проверяем, что это embedding модель
|
// Проверяем, что это embedding модель
|
||||||
if (modelName.includes('embed') || modelName.includes('bge') || modelName.includes('nomic')) {
|
if (model.name.includes('embed') || model.name.includes('bge') || model.name.includes('nomic')) {
|
||||||
allModels.push({
|
allModels.push({
|
||||||
id: modelName,
|
id: model.name,
|
||||||
provider: 'ollama'
|
provider: 'ollama'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (ollamaError) {
|
} catch (ollamaError) {
|
||||||
// console.error('Error checking Ollama embedding models:', ollamaError);
|
// console.error('Error checking Ollama embedding models:', ollamaError);
|
||||||
// Если не удалось проверить Ollama, добавляем базовые embedding модели
|
// Если не удалось проверить Ollama, добавляем базовые embedding модели
|
||||||
|
|||||||
@@ -466,6 +466,191 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет уровень доступа пользователя на основе количества токенов
|
||||||
|
* @param {string} address - Адрес кошелька
|
||||||
|
* @returns {Promise<{level: string, tokenCount: number, hasAccess: boolean}>}
|
||||||
|
*/
|
||||||
|
async getUserAccessLevel(address) {
|
||||||
|
if (!address) {
|
||||||
|
return { level: 'user', tokenCount: 0, hasAccess: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Checking access level for address: ${address}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем токены из базы данных напрямую (как в checkAdminRole)
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
let encryptionKey = 'default-key';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key');
|
||||||
|
if (fs.existsSync(keyPath)) {
|
||||||
|
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
|
||||||
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
console.error('Error reading encryption key:', keyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем токены из базы с расшифровкой
|
||||||
|
const tokensResult = await db.getQuery()(
|
||||||
|
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
||||||
|
[encryptionKey]
|
||||||
|
);
|
||||||
|
const tokens = tokensResult.rows;
|
||||||
|
|
||||||
|
// Получаем RPC провайдеры
|
||||||
|
const rpcProvidersResult = await db.getQuery()(
|
||||||
|
'SELECT id, chain_id, created_at, updated_at, decrypt_text(network_id_encrypted, $1) as network_id, decrypt_text(rpc_url_encrypted, $1) as rpc_url FROM rpc_providers',
|
||||||
|
[encryptionKey]
|
||||||
|
);
|
||||||
|
const rpcProviders = rpcProvidersResult.rows;
|
||||||
|
|
||||||
|
const rpcMap = {};
|
||||||
|
for (const rpc of rpcProviders) {
|
||||||
|
rpcMap[rpc.network_id] = rpc.rpc_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем балансы токенов из блокчейна
|
||||||
|
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
||||||
|
const tokenBalances = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const rpcUrl = rpcMap[token.network];
|
||||||
|
if (!rpcUrl) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||||
|
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider);
|
||||||
|
|
||||||
|
// Получаем баланс с таймаутом
|
||||||
|
const balancePromise = tokenContract.balanceOf(address);
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timeout')), 5000) // Увеличиваем таймаут до 5 секунд
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawBalance = await Promise.race([balancePromise, timeoutPromise]);
|
||||||
|
const balance = ethers.formatUnits(rawBalance, 18);
|
||||||
|
|
||||||
|
tokenBalances.push({
|
||||||
|
network: token.network,
|
||||||
|
tokenAddress: token.address,
|
||||||
|
tokenName: token.name,
|
||||||
|
symbol: '',
|
||||||
|
balance,
|
||||||
|
minBalance: token.min_balance,
|
||||||
|
readonlyThreshold: token.readonly_threshold || 1,
|
||||||
|
editorThreshold: token.editor_threshold || 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[getUserAccessLevel] Token balance for ${token.name} (${token.address}): ${balance}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[getUserAccessLevel] Error getting balance for ${token.name} (${token.address}):`, error.message);
|
||||||
|
// Добавляем токен с нулевым балансом
|
||||||
|
tokenBalances.push({
|
||||||
|
network: token.network,
|
||||||
|
tokenAddress: token.address,
|
||||||
|
tokenName: token.name,
|
||||||
|
symbol: '',
|
||||||
|
balance: '0',
|
||||||
|
minBalance: token.min_balance,
|
||||||
|
readonlyThreshold: token.readonly_threshold || 1,
|
||||||
|
editorThreshold: token.editor_threshold || 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenBalances || !Array.isArray(tokenBalances)) {
|
||||||
|
logger.warn(`No token balances found for address: ${address}`);
|
||||||
|
return { level: 'user', tokenCount: 0, hasAccess: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем сумму токенов с достаточным балансом
|
||||||
|
let validTokenCount = 0;
|
||||||
|
const validTokens = [];
|
||||||
|
|
||||||
|
for (const token of tokenBalances) {
|
||||||
|
const balance = parseFloat(token.balance || '0');
|
||||||
|
const minBalance = parseFloat(token.minBalance || '0');
|
||||||
|
|
||||||
|
if (balance >= minBalance) {
|
||||||
|
validTokenCount += balance; // Суммируем баланс токенов, а не количество сетей
|
||||||
|
validTokens.push({
|
||||||
|
name: token.name,
|
||||||
|
network: token.network,
|
||||||
|
balance: balance,
|
||||||
|
minBalance: minBalance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Token validation for ${address}:`, {
|
||||||
|
totalTokens: tokenBalances.length,
|
||||||
|
validTokens: validTokenCount,
|
||||||
|
validTokenDetails: validTokens
|
||||||
|
});
|
||||||
|
|
||||||
|
// Определяем уровень доступа на основе настроек токенов
|
||||||
|
let accessLevel = 'user';
|
||||||
|
let hasAccess = false;
|
||||||
|
|
||||||
|
// Получаем настройки порогов из токенов (используем самые низкие требования для максимального доступа)
|
||||||
|
let readonlyThreshold = Infinity;
|
||||||
|
let editorThreshold = Infinity;
|
||||||
|
|
||||||
|
if (tokenBalances.length > 0) {
|
||||||
|
// Находим самые низкие пороги среди всех токенов
|
||||||
|
for (const token of tokenBalances) {
|
||||||
|
const tokenReadonlyThreshold = token.readonlyThreshold || 1;
|
||||||
|
const tokenEditorThreshold = token.editorThreshold || 2;
|
||||||
|
|
||||||
|
if (tokenReadonlyThreshold < readonlyThreshold) {
|
||||||
|
readonlyThreshold = tokenReadonlyThreshold;
|
||||||
|
}
|
||||||
|
if (tokenEditorThreshold < editorThreshold) {
|
||||||
|
editorThreshold = tokenEditorThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли токены с порогами, используем дефолтные значения
|
||||||
|
if (readonlyThreshold === Infinity) readonlyThreshold = 1;
|
||||||
|
if (editorThreshold === Infinity) editorThreshold = 2;
|
||||||
|
|
||||||
|
logger.info(`[AuthService] Определены пороги доступа: readonly=${readonlyThreshold}, editor=${editorThreshold} (из ${tokenBalances.length} токенов)`);
|
||||||
|
} else {
|
||||||
|
readonlyThreshold = 1;
|
||||||
|
editorThreshold = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validTokenCount >= editorThreshold) {
|
||||||
|
// Достаточно токенов для полных прав редактора
|
||||||
|
accessLevel = 'editor';
|
||||||
|
hasAccess = true;
|
||||||
|
} else if (validTokenCount > 0) {
|
||||||
|
// Есть токены, но недостаточно для редактора - права только на чтение
|
||||||
|
accessLevel = 'readonly';
|
||||||
|
hasAccess = true;
|
||||||
|
} else {
|
||||||
|
// Нет токенов - обычный пользователь
|
||||||
|
accessLevel = 'user';
|
||||||
|
hasAccess = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Access level determined for ${address}: ${accessLevel} (${validTokenCount} tokens)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: accessLevel,
|
||||||
|
tokenCount: validTokenCount,
|
||||||
|
hasAccess: hasAccess,
|
||||||
|
validTokens: validTokens
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in getUserAccessLevel: ${error.message}`);
|
||||||
|
return { level: 'user', tokenCount: 0, hasAccess: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Добавляем псевдоним функции checkAdminRole для обратной совместимости
|
// Добавляем псевдоним функции checkAdminRole для обратной совместимости
|
||||||
async checkAdminTokens(address) {
|
async checkAdminTokens(address) {
|
||||||
if (!address) return false;
|
if (!address) return false;
|
||||||
@@ -473,9 +658,11 @@ class AuthService {
|
|||||||
logger.info(`Checking admin tokens for address: ${address}`);
|
logger.info(`Checking admin tokens for address: ${address}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isAdmin = await checkAdminRole(address);
|
// Используем новую функцию для определения уровня доступа
|
||||||
|
const accessLevel = await this.getUserAccessLevel(address);
|
||||||
|
const isAdmin = accessLevel.hasAccess; // Любой доступ выше 'user' считается админским
|
||||||
|
|
||||||
// Обновляем роль пользователя в базе данных, если есть админские токены
|
// Обновляем роль пользователя в базе данных
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
try {
|
try {
|
||||||
// Получаем ключ шифрования
|
// Получаем ключ шифрования
|
||||||
@@ -503,16 +690,17 @@ class AuthService {
|
|||||||
|
|
||||||
if (userResult.rows.length > 0) {
|
if (userResult.rows.length > 0) {
|
||||||
const userId = userResult.rows[0].id;
|
const userId = userResult.rows[0].id;
|
||||||
// Обновляем роль пользователя
|
// Обновляем роль пользователя с учетом уровня доступа
|
||||||
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['admin', userId]);
|
const role = accessLevel.level;
|
||||||
logger.info(`Updated user ${userId} role to admin based on token holdings`);
|
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [role, userId]);
|
||||||
|
logger.info(`Updated user ${userId} role to ${role} based on token holdings (${accessLevel.tokenCount} tokens)`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating user role:', error);
|
logger.error('Error updating user role:', error);
|
||||||
// Продолжаем выполнение, даже если обновление роли не удалось
|
// Продолжаем выполнение, даже если обновление роли не удалось
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Если пользователь не является администратором, сбрасываем роль на "user", если она была "admin"
|
// Если пользователь не имеет доступа, сбрасываем роль на "user"
|
||||||
try {
|
try {
|
||||||
// Получаем ключ шифрования
|
// Получаем ключ шифрования
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -536,10 +724,10 @@ class AuthService {
|
|||||||
[address.toLowerCase(), encryptionKey]
|
[address.toLowerCase(), encryptionKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userResult.rows.length > 0 && userResult.rows[0].role === 'admin') {
|
if (userResult.rows.length > 0 && userResult.rows[0].role !== 'user') {
|
||||||
const userId = userResult.rows[0].id;
|
const userId = userResult.rows[0].id;
|
||||||
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['user', userId]);
|
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', ['user', userId]);
|
||||||
logger.info(`Reset user ${userId} role from admin to user (no tokens found)`);
|
logger.info(`Reset user ${userId} role to user (no valid tokens found)`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating user role:', error);
|
logger.error('Error updating user role:', error);
|
||||||
@@ -594,20 +782,18 @@ class AuthService {
|
|||||||
const address = user.address;
|
const address = user.address;
|
||||||
const currentRole = user.role;
|
const currentRole = user.role;
|
||||||
|
|
||||||
logger.info(`Rechecking admin status for user ${user.id} with address ${address}`);
|
logger.info(`Rechecking access level for user ${user.id} with address ${address}`);
|
||||||
|
|
||||||
// Проверяем баланс токенов
|
// Получаем новый уровень доступа
|
||||||
const isAdmin = await checkAdminRole(address);
|
const accessLevel = await this.getUserAccessLevel(address);
|
||||||
|
const newRole = accessLevel.hasAccess ? accessLevel.level : 'user';
|
||||||
// Определяем новую роль
|
|
||||||
const newRole = isAdmin ? 'admin' : 'user';
|
|
||||||
|
|
||||||
// Обновляем роль только если она изменилась
|
// Обновляем роль только если она изменилась
|
||||||
if (currentRole !== newRole) {
|
if (currentRole !== newRole) {
|
||||||
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [newRole, user.id]);
|
await db.getQuery()('UPDATE users SET role = $1 WHERE id = $2', [newRole, user.id]);
|
||||||
logger.info(`Updated user ${user.id} role from ${currentRole} to ${newRole} (address: ${address})`);
|
logger.info(`Updated user ${user.id} role from ${currentRole} to ${newRole} (address: ${address}, tokens: ${accessLevel.tokenCount})`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`User ${user.id} role unchanged: ${currentRole} (address: ${address})`);
|
logger.info(`User ${user.id} role unchanged: ${currentRole} (address: ${address}, tokens: ${accessLevel.tokenCount})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (userError) {
|
} catch (userError) {
|
||||||
|
|||||||
@@ -27,13 +27,25 @@ async function saveAllAuthTokens(authTokens) {
|
|||||||
name: token.name,
|
name: token.name,
|
||||||
address: token.address,
|
address: token.address,
|
||||||
network: token.network,
|
network: token.network,
|
||||||
min_balance: token.minBalance == null ? 0 : Number(token.minBalance)
|
min_balance: token.minBalance == null ? 0 : Number(token.minBalance),
|
||||||
|
readonly_threshold: token.readonlyThreshold == null ? 1 : Number(token.readonlyThreshold),
|
||||||
|
editor_threshold: token.editorThreshold == null ? 2 : Number(token.editorThreshold)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertAuthToken(token) {
|
async function upsertAuthToken(token) {
|
||||||
|
console.log('[AuthTokenService] Получены данные токена:', token);
|
||||||
|
console.log('[AuthTokenService] token.readonlyThreshold:', token.readonlyThreshold, 'тип:', typeof token.readonlyThreshold);
|
||||||
|
console.log('[AuthTokenService] token.editorThreshold:', token.editorThreshold, 'тип:', typeof token.editorThreshold);
|
||||||
|
|
||||||
const minBalance = token.minBalance == null ? 0 : Number(token.minBalance);
|
const minBalance = token.minBalance == null ? 0 : Number(token.minBalance);
|
||||||
|
const readonlyThreshold = (token.readonlyThreshold === null || token.readonlyThreshold === undefined || token.readonlyThreshold === '') ? 1 : Number(token.readonlyThreshold);
|
||||||
|
const editorThreshold = (token.editorThreshold === null || token.editorThreshold === undefined || token.editorThreshold === '') ? 2 : Number(token.editorThreshold);
|
||||||
|
|
||||||
|
console.log('[AuthTokenService] Вычисленные значения:');
|
||||||
|
console.log('[AuthTokenService] readonlyThreshold:', readonlyThreshold);
|
||||||
|
console.log('[AuthTokenService] editorThreshold:', editorThreshold);
|
||||||
|
|
||||||
// Проверяем, существует ли токен
|
// Проверяем, существует ли токен
|
||||||
const existingTokens = await encryptedDb.getData('auth_tokens', {
|
const existingTokens = await encryptedDb.getData('auth_tokens', {
|
||||||
@@ -45,7 +57,9 @@ async function upsertAuthToken(token) {
|
|||||||
// Обновляем существующий токен
|
// Обновляем существующий токен
|
||||||
await encryptedDb.saveData('auth_tokens', {
|
await encryptedDb.saveData('auth_tokens', {
|
||||||
name: token.name,
|
name: token.name,
|
||||||
min_balance: minBalance
|
min_balance: minBalance,
|
||||||
|
readonly_threshold: readonlyThreshold,
|
||||||
|
editor_threshold: editorThreshold
|
||||||
}, {
|
}, {
|
||||||
address: token.address,
|
address: token.address,
|
||||||
network: token.network
|
network: token.network
|
||||||
@@ -56,7 +70,9 @@ async function upsertAuthToken(token) {
|
|||||||
name: token.name,
|
name: token.name,
|
||||||
address: token.address,
|
address: token.address,
|
||||||
network: token.network,
|
network: token.network,
|
||||||
min_balance: minBalance
|
min_balance: minBalance,
|
||||||
|
readonly_threshold: readonlyThreshold,
|
||||||
|
editor_threshold: editorThreshold
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,12 +289,23 @@ class EncryptedDataService {
|
|||||||
.map((key, index) => `${quoteReservedWord(key)} = ${allData[key]}`)
|
.map((key, index) => `${quoteReservedWord(key)} = ${allData[key]}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
const whereClause = Object.keys(whereConditions)
|
const whereClause = Object.keys(whereConditions)
|
||||||
.map((key, index) => `${quoteReservedWord(key)} = $${paramIndex + index}`)
|
.map((key, index) => {
|
||||||
|
// Для WHERE условий используем зашифрованные имена колонок
|
||||||
|
const encryptedColumn = columns.find(col => col.column_name === `${key}_encrypted`);
|
||||||
|
if (encryptedColumn) {
|
||||||
|
// Для зашифрованных колонок используем encrypt_text для сравнения
|
||||||
|
return `${quoteReservedWord(`${key}_encrypted`)} = encrypt_text($${paramIndex + index}, ${hasEncryptedFields ? '$1' : 'NULL'})`;
|
||||||
|
} else {
|
||||||
|
// Для незашифрованных колонок используем обычное сравнение
|
||||||
|
return `${quoteReservedWord(key)} = $${paramIndex + index}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
.join(' AND ');
|
.join(' AND ');
|
||||||
|
|
||||||
const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`;
|
const query = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING *`;
|
||||||
const allParams = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData), ...Object.values(whereConditions)] : [...Object.values(filteredData), ...Object.values(whereConditions)];
|
const allParams = hasEncryptedFields ? [this.encryptionKey, ...Object.values(filteredData), ...Object.values(whereConditions)] : [...Object.values(filteredData), ...Object.values(whereConditions)];
|
||||||
|
|
||||||
|
|
||||||
const { rows } = await db.getQuery()(query, allParams);
|
const { rows } = await db.getQuery()(query, allParams);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async function getUserTokenBalances(address) {
|
|||||||
|
|
||||||
// Получаем токены и RPC с расшифровкой
|
// Получаем токены и RPC с расшифровкой
|
||||||
const tokensResult = await db.getQuery()(
|
const tokensResult = await db.getQuery()(
|
||||||
'SELECT id, min_balance, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
||||||
[encryptionKey]
|
[encryptionKey]
|
||||||
);
|
);
|
||||||
const tokens = tokensResult.rows;
|
const tokens = tokensResult.rows;
|
||||||
@@ -57,18 +57,42 @@ async function getUserTokenBalances(address) {
|
|||||||
|
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const rpcUrl = rpcMap[token.network];
|
const rpcUrl = rpcMap[token.network];
|
||||||
if (!rpcUrl) continue;
|
if (!rpcUrl) {
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
logger.warn(`[tokenBalanceService] RPC URL не найден для сети ${token.network}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем провайдер с таймаутом
|
||||||
|
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
|
||||||
|
polling: false,
|
||||||
|
staticNetwork: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Устанавливаем таймаут для запросов
|
||||||
|
provider._getConnection().timeout = 10000; // 10 секунд
|
||||||
|
|
||||||
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider);
|
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, provider);
|
||||||
let balance = '0';
|
let balance = '0';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawBalance = await tokenContract.balanceOf(address);
|
logger.info(`[tokenBalanceService] Получение баланса для ${token.name} (${token.address}) в сети ${token.network} для адреса ${address}`);
|
||||||
|
|
||||||
|
// Создаем промис с таймаутом
|
||||||
|
const balancePromise = tokenContract.balanceOf(address);
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Request timeout')), 10000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawBalance = await Promise.race([balancePromise, timeoutPromise]);
|
||||||
balance = ethers.formatUnits(rawBalance, 18);
|
balance = ethers.formatUnits(rawBalance, 18);
|
||||||
|
|
||||||
if (!balance || isNaN(Number(balance))) balance = '0';
|
if (!balance || isNaN(Number(balance))) balance = '0';
|
||||||
|
|
||||||
|
logger.info(`[tokenBalanceService] Баланс получен для ${token.name}: ${balance}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[tokenBalanceService] Ошибка получения баланса для ${token.name} (${token.address}) в сети ${token.network}:`,
|
`[tokenBalanceService] Ошибка получения баланса для ${token.name} (${token.address}) в сети ${token.network}:`,
|
||||||
e
|
e.message || e
|
||||||
);
|
);
|
||||||
balance = '0';
|
balance = '0';
|
||||||
}
|
}
|
||||||
@@ -79,6 +103,8 @@ async function getUserTokenBalances(address) {
|
|||||||
symbol: token.symbol || '',
|
symbol: token.symbol || '',
|
||||||
balance,
|
balance,
|
||||||
minBalance: token.min_balance,
|
minBalance: token.min_balance,
|
||||||
|
readonlyThreshold: token.readonly_threshold || 1,
|
||||||
|
editorThreshold: token.editor_threshold || 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,617 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
const hre = require('hardhat');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// Подбираем безопасные gas/fee для разных сетей (включая L2)
|
|
||||||
async function getFeeOverrides(provider, { minPriorityGwei = 1n, minFeeGwei = 20n } = {}) {
|
|
||||||
const fee = await provider.getFeeData();
|
|
||||||
const overrides = {};
|
|
||||||
const minPriority = (await (async () => hre.ethers.parseUnits(minPriorityGwei.toString(), 'gwei'))());
|
|
||||||
const minFee = (await (async () => hre.ethers.parseUnits(minFeeGwei.toString(), 'gwei'))());
|
|
||||||
if (fee.maxFeePerGas) {
|
|
||||||
overrides.maxFeePerGas = fee.maxFeePerGas < minFee ? minFee : fee.maxFeePerGas;
|
|
||||||
overrides.maxPriorityFeePerGas = (fee.maxPriorityFeePerGas && fee.maxPriorityFeePerGas > 0n)
|
|
||||||
? fee.maxPriorityFeePerGas
|
|
||||||
: minPriority;
|
|
||||||
} else if (fee.gasPrice) {
|
|
||||||
overrides.gasPrice = fee.gasPrice < minFee ? minFee : fee.gasPrice;
|
|
||||||
}
|
|
||||||
return overrides;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deployInNetwork(rpcUrl, pk, salt, initCodeHash, targetDLENonce, dleInit) {
|
|
||||||
const { ethers } = hre;
|
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
||||||
const wallet = new ethers.Wallet(pk, provider);
|
|
||||||
const net = await provider.getNetwork();
|
|
||||||
|
|
||||||
// DEBUG: базовая информация по сети
|
|
||||||
try {
|
|
||||||
const calcInitHash = ethers.keccak256(dleInit);
|
|
||||||
const saltLen = ethers.getBytes(salt).length;
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} rpc=${rpcUrl}`);
|
|
||||||
console.log(`[MULTI_DBG] wallet=${wallet.address} targetDLENonce=${targetDLENonce}`);
|
|
||||||
console.log(`[MULTI_DBG] saltLenBytes=${saltLen} salt=${salt}`);
|
|
||||||
console.log(`[MULTI_DBG] initCodeHash(provided)=${initCodeHash}`);
|
|
||||||
console.log(`[MULTI_DBG] initCodeHash(calculated)=${calcInitHash}`);
|
|
||||||
console.log(`[MULTI_DBG] dleInit.lenBytes=${ethers.getBytes(dleInit).length} head16=${dleInit.slice(0, 34)}...`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('[MULTI_DBG] precheck error', e?.message || e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Выравнивание nonce до targetDLENonce нулевыми транзакциями (если нужно)
|
|
||||||
let current = await provider.getTransactionCount(wallet.address, 'pending');
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} current nonce=${current} target=${targetDLENonce}`);
|
|
||||||
|
|
||||||
if (current > targetDLENonce) {
|
|
||||||
throw new Error(`Current nonce ${current} > targetDLENonce ${targetDLENonce} on chainId=${Number(net.chainId)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current < targetDLENonce) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} aligning nonce from ${current} to ${targetDLENonce} (${targetDLENonce - current} transactions needed)`);
|
|
||||||
|
|
||||||
// Используем burn address для более надежных транзакций
|
|
||||||
const burnAddress = "0x000000000000000000000000000000000000dEaD";
|
|
||||||
|
|
||||||
while (current < targetDLENonce) {
|
|
||||||
const overrides = await getFeeOverrides(provider);
|
|
||||||
let gasLimit = 21000; // минимальный gas для обычной транзакции
|
|
||||||
let sent = false;
|
|
||||||
let lastErr = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3 && !sent; attempt++) {
|
|
||||||
try {
|
|
||||||
const txReq = {
|
|
||||||
to: burnAddress, // отправляем на burn address вместо своего адреса
|
|
||||||
value: 0n,
|
|
||||||
nonce: current,
|
|
||||||
gasLimit,
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} sending filler tx nonce=${current} attempt=${attempt + 1}`);
|
|
||||||
const txFill = await wallet.sendTransaction(txReq);
|
|
||||||
await txFill.wait();
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} filler tx nonce=${current} confirmed`);
|
|
||||||
sent = true;
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} filler tx nonce=${current} attempt=${attempt + 1} failed: ${e?.message || e}`);
|
|
||||||
|
|
||||||
if (String(e?.message || '').toLowerCase().includes('intrinsic gas too low') && attempt < 2) {
|
|
||||||
gasLimit = 50000; // увеличиваем gas limit
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(e?.message || '').toLowerCase().includes('nonce too low') && attempt < 2) {
|
|
||||||
// Обновляем nonce и пробуем снова
|
|
||||||
current = await provider.getTransactionCount(wallet.address, 'pending');
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} updated nonce to ${current}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sent) {
|
|
||||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} failed to send filler tx for nonce=${current}`);
|
|
||||||
throw lastErr || new Error('filler tx failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
current++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce alignment completed, current nonce=${current}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce already aligned at ${current}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Деплой DLE напрямую на согласованном nonce
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying DLE directly with nonce=${targetDLENonce}`);
|
|
||||||
|
|
||||||
const feeOverrides = await getFeeOverrides(provider);
|
|
||||||
let gasLimit;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Оцениваем газ для деплоя DLE
|
|
||||||
const est = await wallet.estimateGas({ data: dleInit, ...feeOverrides }).catch(() => null);
|
|
||||||
|
|
||||||
// Рассчитываем доступный gasLimit из баланса
|
|
||||||
const balance = await provider.getBalance(wallet.address, 'latest');
|
|
||||||
const effPrice = feeOverrides.maxFeePerGas || feeOverrides.gasPrice || 0n;
|
|
||||||
const reserve = hre.ethers.parseEther('0.005');
|
|
||||||
const maxByBalance = effPrice > 0n && balance > reserve ? (balance - reserve) / effPrice : 3_000_000n;
|
|
||||||
const fallbackGas = maxByBalance > 5_000_000n ? 5_000_000n : (maxByBalance < 2_500_000n ? 2_500_000n : maxByBalance);
|
|
||||||
gasLimit = est ? (est + est / 5n) : fallbackGas;
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} estGas=${est?.toString?.()||'null'} effGasPrice=${effPrice?.toString?.()||'0'} maxByBalance=${maxByBalance.toString()} chosenGasLimit=${gasLimit.toString()}`);
|
|
||||||
} catch (_) {
|
|
||||||
gasLimit = 3_000_000n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вычисляем предсказанный адрес DLE
|
|
||||||
const predictedAddress = ethers.getCreateAddress({
|
|
||||||
from: wallet.address,
|
|
||||||
nonce: targetDLENonce
|
|
||||||
});
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} predicted DLE address=${predictedAddress}`);
|
|
||||||
|
|
||||||
// Проверяем, не развернут ли уже контракт
|
|
||||||
const existingCode = await provider.getCode(predictedAddress);
|
|
||||||
if (existingCode && existingCode !== '0x') {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE already exists at predictedAddress, skip deploy`);
|
|
||||||
return { address: predictedAddress, chainId: Number(net.chainId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Деплоим DLE
|
|
||||||
let tx;
|
|
||||||
try {
|
|
||||||
tx = await wallet.sendTransaction({
|
|
||||||
data: dleInit,
|
|
||||||
nonce: targetDLENonce,
|
|
||||||
gasLimit,
|
|
||||||
...feeOverrides
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploy error(first): ${e?.message || e}`);
|
|
||||||
// Повторная попытка с обновленным nonce
|
|
||||||
const updatedNonce = await provider.getTransactionCount(wallet.address, 'pending');
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} retry deploy with nonce=${updatedNonce}`);
|
|
||||||
tx = await wallet.sendTransaction({
|
|
||||||
data: dleInit,
|
|
||||||
nonce: updatedNonce,
|
|
||||||
gasLimit,
|
|
||||||
...feeOverrides
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rc = await tx.wait();
|
|
||||||
const deployedAddress = rc.contractAddress || predictedAddress;
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE deployed at=${deployedAddress}`);
|
|
||||||
return { address: deployedAddress, chainId: Number(net.chainId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Деплой модулей в одной сети
|
|
||||||
async function deployModulesInNetwork(rpcUrl, pk, dleAddress, params) {
|
|
||||||
const { ethers } = hre;
|
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
||||||
const wallet = new ethers.Wallet(pk, provider);
|
|
||||||
const net = await provider.getNetwork();
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying modules...`);
|
|
||||||
|
|
||||||
const modules = {};
|
|
||||||
|
|
||||||
// Получаем начальный nonce для всех модулей
|
|
||||||
let currentNonce = await wallet.getNonce();
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} starting nonce for modules: ${currentNonce}`);
|
|
||||||
|
|
||||||
// Функция для безопасного деплоя с правильным nonce
|
|
||||||
async function deployWithNonce(contractFactory, args, moduleName) {
|
|
||||||
try {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying ${moduleName} with nonce: ${currentNonce}`);
|
|
||||||
|
|
||||||
// Проверяем, что nonce актуален
|
|
||||||
const actualNonce = await wallet.getNonce();
|
|
||||||
if (actualNonce > currentNonce) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch, updating from ${currentNonce} to ${actualNonce}`);
|
|
||||||
currentNonce = actualNonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contract = await contractFactory.connect(wallet).deploy(...args);
|
|
||||||
await contract.waitForDeployment();
|
|
||||||
const address = await contract.getAddress();
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployed at: ${address}`);
|
|
||||||
currentNonce++;
|
|
||||||
return address;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployment failed:`, error.message);
|
|
||||||
// Даже при ошибке увеличиваем nonce, чтобы не было конфликтов
|
|
||||||
currentNonce++;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Деплой TreasuryModule
|
|
||||||
const TreasuryModule = await hre.ethers.getContractFactory('TreasuryModule');
|
|
||||||
modules.treasuryModule = await deployWithNonce(
|
|
||||||
TreasuryModule,
|
|
||||||
[dleAddress, Number(net.chainId), wallet.address], // _dleContract, _chainId, _emergencyAdmin
|
|
||||||
'TreasuryModule'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Деплой TimelockModule
|
|
||||||
const TimelockModule = await hre.ethers.getContractFactory('TimelockModule');
|
|
||||||
modules.timelockModule = await deployWithNonce(
|
|
||||||
TimelockModule,
|
|
||||||
[dleAddress], // _dleContract
|
|
||||||
'TimelockModule'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Деплой DLEReader
|
|
||||||
const DLEReader = await hre.ethers.getContractFactory('DLEReader');
|
|
||||||
modules.dleReader = await deployWithNonce(
|
|
||||||
DLEReader,
|
|
||||||
[dleAddress], // _dleContract
|
|
||||||
'DLEReader'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Инициализация модулей в DLE
|
|
||||||
try {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing modules in DLE with nonce: ${currentNonce}`);
|
|
||||||
|
|
||||||
// Проверяем, что nonce актуален
|
|
||||||
const actualNonce = await wallet.getNonce();
|
|
||||||
if (actualNonce > currentNonce) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before module init, updating from ${currentNonce} to ${actualNonce}`);
|
|
||||||
currentNonce = actualNonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
|
||||||
|
|
||||||
// Проверяем, что все модули задеплоены
|
|
||||||
const treasuryAddress = modules.treasuryModule;
|
|
||||||
const timelockAddress = modules.timelockModule;
|
|
||||||
const readerAddress = modules.dleReader;
|
|
||||||
|
|
||||||
if (treasuryAddress && timelockAddress && readerAddress) {
|
|
||||||
// Инициализация базовых модулей
|
|
||||||
await dleContract.initializeBaseModules(treasuryAddress, timelockAddress, readerAddress);
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} base modules initialized`);
|
|
||||||
currentNonce++;
|
|
||||||
} else {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} skipping module initialization - not all modules deployed`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} module initialization failed:`, error.message);
|
|
||||||
// Даже при ошибке увеличиваем nonce
|
|
||||||
currentNonce++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализация logoURI
|
|
||||||
try {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing logoURI with nonce: ${currentNonce}`);
|
|
||||||
|
|
||||||
// Проверяем, что nonce актуален
|
|
||||||
const actualNonce = await wallet.getNonce();
|
|
||||||
if (actualNonce > currentNonce) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before logoURI init, updating from ${currentNonce} to ${actualNonce}`);
|
|
||||||
currentNonce = actualNonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Используем логотип из параметров деплоя или fallback
|
|
||||||
const logoURL = params.logoURI || "https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE";
|
|
||||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
|
||||||
await dleContract.initializeLogoURI(logoURL);
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialized: ${logoURL}`);
|
|
||||||
currentNonce++;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialization failed: ${e.message}`);
|
|
||||||
// Fallback на базовый логотип
|
|
||||||
try {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} trying fallback logoURI with nonce: ${currentNonce}`);
|
|
||||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
|
||||||
await dleContract.initializeLogoURI("https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE");
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI initialized`);
|
|
||||||
currentNonce++;
|
|
||||||
} catch (fallbackError) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI also failed: ${fallbackError.message}`);
|
|
||||||
// Даже при ошибке увеличиваем nonce
|
|
||||||
currentNonce++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return modules;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Деплой модулей во всех сетях
|
|
||||||
async function deployModulesInAllNetworks(networks, pk, dleAddress, params) {
|
|
||||||
const moduleResults = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < networks.length; i++) {
|
|
||||||
const rpcUrl = networks[i];
|
|
||||||
console.log(`[MULTI_DBG] deploying modules to network ${i + 1}/${networks.length}: ${rpcUrl}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const modules = await deployModulesInNetwork(rpcUrl, pk, dleAddress, params);
|
|
||||||
moduleResults.push(modules);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MULTI_DBG] Failed to deploy modules in network ${i + 1}:`, error.message);
|
|
||||||
moduleResults.push({ error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Верификация контрактов в одной сети
|
|
||||||
async function verifyContractsInNetwork(rpcUrl, pk, dleAddress, modules, params) {
|
|
||||||
const { ethers } = hre;
|
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
||||||
const wallet = new ethers.Wallet(pk, provider);
|
|
||||||
const net = await provider.getNetwork();
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} starting verification...`);
|
|
||||||
|
|
||||||
const verification = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Верификация DLE
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying DLE...`);
|
|
||||||
await hre.run("verify:verify", {
|
|
||||||
address: dleAddress,
|
|
||||||
constructorArguments: [
|
|
||||||
{
|
|
||||||
name: params.name || '',
|
|
||||||
symbol: params.symbol || '',
|
|
||||||
location: params.location || '',
|
|
||||||
coordinates: params.coordinates || '',
|
|
||||||
jurisdiction: params.jurisdiction || 0,
|
|
||||||
oktmo: params.oktmo || '',
|
|
||||||
okvedCodes: params.okvedCodes || [],
|
|
||||||
kpp: params.kpp ? BigInt(params.kpp) : 0n,
|
|
||||||
quorumPercentage: params.quorumPercentage || 51,
|
|
||||||
initialPartners: params.initialPartners || [],
|
|
||||||
initialAmounts: (params.initialAmounts || []).map(amount => BigInt(amount)),
|
|
||||||
supportedChainIds: (params.supportedChainIds || []).map(id => BigInt(id))
|
|
||||||
},
|
|
||||||
BigInt(params.currentChainId || params.supportedChainIds?.[0] || 1),
|
|
||||||
params.initializer || params.initialPartners?.[0] || "0x0000000000000000000000000000000000000000"
|
|
||||||
],
|
|
||||||
});
|
|
||||||
verification.dle = 'success';
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification successful`);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification failed: ${error.message}`);
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification error details:`, error);
|
|
||||||
verification.dle = 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Верификация модулей
|
|
||||||
if (modules && !modules.error) {
|
|
||||||
try {
|
|
||||||
// Верификация TreasuryModule
|
|
||||||
if (modules.treasuryModule) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying TreasuryModule...`);
|
|
||||||
await hre.run("verify:verify", {
|
|
||||||
address: modules.treasuryModule,
|
|
||||||
constructorArguments: [
|
|
||||||
dleAddress, // _dleContract
|
|
||||||
Number(net.chainId), // _chainId
|
|
||||||
wallet.address // _emergencyAdmin
|
|
||||||
],
|
|
||||||
});
|
|
||||||
verification.treasuryModule = 'success';
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TreasuryModule verification successful`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TreasuryModule verification failed: ${error.message}`);
|
|
||||||
verification.treasuryModule = 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Верификация TimelockModule
|
|
||||||
if (modules.timelockModule) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying TimelockModule...`);
|
|
||||||
await hre.run("verify:verify", {
|
|
||||||
address: modules.timelockModule,
|
|
||||||
constructorArguments: [
|
|
||||||
dleAddress // _dleContract
|
|
||||||
],
|
|
||||||
});
|
|
||||||
verification.timelockModule = 'success';
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TimelockModule verification successful`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TimelockModule verification failed: ${error.message}`);
|
|
||||||
verification.timelockModule = 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Верификация DLEReader
|
|
||||||
if (modules.dleReader) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} verifying DLEReader...`);
|
|
||||||
await hre.run("verify:verify", {
|
|
||||||
address: modules.dleReader,
|
|
||||||
constructorArguments: [
|
|
||||||
dleAddress // _dleContract
|
|
||||||
],
|
|
||||||
});
|
|
||||||
verification.dleReader = 'success';
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLEReader verification successful`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLEReader verification failed: ${error.message}`);
|
|
||||||
verification.dleReader = 'failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return verification;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Верификация контрактов во всех сетях
|
|
||||||
async function verifyContractsInAllNetworks(networks, pk, dleAddress, moduleResults, params) {
|
|
||||||
const verificationResults = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < networks.length; i++) {
|
|
||||||
const rpcUrl = networks[i];
|
|
||||||
console.log(`[MULTI_DBG] verifying contracts in network ${i + 1}/${networks.length}: ${rpcUrl}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verification = await verifyContractsInNetwork(rpcUrl, pk, dleAddress, moduleResults[i], params);
|
|
||||||
verificationResults.push(verification);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MULTI_DBG] Failed to verify contracts in network ${i + 1}:`, error.message);
|
|
||||||
verificationResults.push({ error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return verificationResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { ethers } = hre;
|
|
||||||
|
|
||||||
// Загружаем параметры из файла
|
|
||||||
const paramsPath = path.join(__dirname, './current-params.json');
|
|
||||||
if (!fs.existsSync(paramsPath)) {
|
|
||||||
throw new Error('Файл параметров не найден: ' + paramsPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = JSON.parse(fs.readFileSync(paramsPath, 'utf8'));
|
|
||||||
console.log('[MULTI_DBG] Загружены параметры:', {
|
|
||||||
name: params.name,
|
|
||||||
symbol: params.symbol,
|
|
||||||
supportedChainIds: params.supportedChainIds,
|
|
||||||
CREATE2_SALT: params.CREATE2_SALT
|
|
||||||
});
|
|
||||||
|
|
||||||
const pk = process.env.PRIVATE_KEY;
|
|
||||||
const salt = params.CREATE2_SALT;
|
|
||||||
const networks = params.rpcUrls || [];
|
|
||||||
|
|
||||||
if (!pk) throw new Error('Env: PRIVATE_KEY');
|
|
||||||
if (!salt) throw new Error('CREATE2_SALT not found in params');
|
|
||||||
if (networks.length === 0) throw new Error('RPC URLs not found in params');
|
|
||||||
|
|
||||||
// Prepare init code once
|
|
||||||
const DLE = await hre.ethers.getContractFactory('DLE');
|
|
||||||
const dleConfig = {
|
|
||||||
name: params.name || '',
|
|
||||||
symbol: params.symbol || '',
|
|
||||||
location: params.location || '',
|
|
||||||
coordinates: params.coordinates || '',
|
|
||||||
jurisdiction: params.jurisdiction || 0,
|
|
||||||
oktmo: params.oktmo || '',
|
|
||||||
okvedCodes: params.okvedCodes || [],
|
|
||||||
kpp: params.kpp ? BigInt(params.kpp) : 0n,
|
|
||||||
quorumPercentage: params.quorumPercentage || 51,
|
|
||||||
initialPartners: params.initialPartners || [],
|
|
||||||
initialAmounts: (params.initialAmounts || []).map(amount => BigInt(amount)),
|
|
||||||
supportedChainIds: (params.supportedChainIds || []).map(id => BigInt(id))
|
|
||||||
};
|
|
||||||
const deployTx = await DLE.getDeployTransaction(dleConfig, BigInt(params.currentChainId || params.supportedChainIds?.[0] || 1), params.initializer || params.initialPartners?.[0] || "0x0000000000000000000000000000000000000000");
|
|
||||||
const dleInit = deployTx.data;
|
|
||||||
const initCodeHash = ethers.keccak256(dleInit);
|
|
||||||
|
|
||||||
// DEBUG: глобальные значения
|
|
||||||
try {
|
|
||||||
const saltLen = ethers.getBytes(salt).length;
|
|
||||||
console.log(`[MULTI_DBG] GLOBAL saltLenBytes=${saltLen} salt=${salt}`);
|
|
||||||
console.log(`[MULTI_DBG] GLOBAL initCodeHash(calculated)=${initCodeHash}`);
|
|
||||||
console.log(`[MULTI_DBG] GLOBAL dleInit.lenBytes=${ethers.getBytes(dleInit).length} head16=${dleInit.slice(0, 34)}...`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('[MULTI_DBG] GLOBAL precheck error', e?.message || e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подготовим провайдеры и вычислим общий nonce для DLE
|
|
||||||
const providers = networks.map(u => new hre.ethers.JsonRpcProvider(u));
|
|
||||||
const wallets = providers.map(p => new hre.ethers.Wallet(pk, p));
|
|
||||||
const nonces = [];
|
|
||||||
for (let i = 0; i < providers.length; i++) {
|
|
||||||
const n = await providers[i].getTransactionCount(wallets[i].address, 'pending');
|
|
||||||
nonces.push(n);
|
|
||||||
}
|
|
||||||
const targetDLENonce = Math.max(...nonces);
|
|
||||||
console.log(`[MULTI_DBG] nonces=${JSON.stringify(nonces)} targetDLENonce=${targetDLENonce}`);
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < networks.length; i++) {
|
|
||||||
const rpcUrl = networks[i];
|
|
||||||
console.log(`[MULTI_DBG] deploying to network ${i + 1}/${networks.length}: ${rpcUrl}`);
|
|
||||||
const r = await deployInNetwork(rpcUrl, pk, salt, initCodeHash, targetDLENonce, dleInit);
|
|
||||||
results.push({ rpcUrl, ...r });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что все адреса одинаковые
|
|
||||||
const addresses = results.map(r => r.address);
|
|
||||||
const uniqueAddresses = [...new Set(addresses)];
|
|
||||||
|
|
||||||
if (uniqueAddresses.length > 1) {
|
|
||||||
console.error('[MULTI_DBG] ERROR: DLE addresses are different across networks!');
|
|
||||||
console.error('[MULTI_DBG] addresses:', uniqueAddresses);
|
|
||||||
throw new Error('Nonce alignment failed - addresses are different');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[MULTI_DBG] SUCCESS: All DLE addresses are identical:', uniqueAddresses[0]);
|
|
||||||
|
|
||||||
// Деплой модулей во всех сетях
|
|
||||||
console.log('[MULTI_DBG] Starting module deployment...');
|
|
||||||
const moduleResults = await deployModulesInAllNetworks(networks, pk, uniqueAddresses[0], params);
|
|
||||||
|
|
||||||
// Верификация контрактов
|
|
||||||
console.log('[MULTI_DBG] Starting contract verification...');
|
|
||||||
const verificationResults = await verifyContractsInAllNetworks(networks, pk, uniqueAddresses[0], moduleResults, params);
|
|
||||||
|
|
||||||
// Объединяем результаты
|
|
||||||
const finalResults = results.map((result, index) => ({
|
|
||||||
...result,
|
|
||||||
modules: moduleResults[index] || {},
|
|
||||||
verification: verificationResults[index] || {}
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log('MULTICHAIN_DEPLOY_RESULT', JSON.stringify(finalResults));
|
|
||||||
|
|
||||||
// Сохраняем информацию о модулях в отдельный файл для каждого DLE
|
|
||||||
// Добавляем информацию о сетях (chainId, rpcUrl)
|
|
||||||
const modulesInfo = {
|
|
||||||
dleAddress: uniqueAddresses[0],
|
|
||||||
networks: networks.map((rpcUrl, index) => ({
|
|
||||||
rpcUrl: rpcUrl,
|
|
||||||
chainId: null, // Будет заполнено ниже
|
|
||||||
networkName: null // Будет заполнено ниже
|
|
||||||
})),
|
|
||||||
modules: moduleResults,
|
|
||||||
verification: verificationResults,
|
|
||||||
deployTimestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Получаем chainId для каждой сети
|
|
||||||
for (let i = 0; i < networks.length; i++) {
|
|
||||||
try {
|
|
||||||
const provider = new hre.ethers.JsonRpcProvider(networks[i]);
|
|
||||||
const network = await provider.getNetwork();
|
|
||||||
modulesInfo.networks[i].chainId = Number(network.chainId);
|
|
||||||
|
|
||||||
// Определяем название сети по chainId
|
|
||||||
const networkNames = {
|
|
||||||
1: 'Ethereum Mainnet',
|
|
||||||
5: 'Goerli',
|
|
||||||
11155111: 'Sepolia',
|
|
||||||
137: 'Polygon Mainnet',
|
|
||||||
80001: 'Mumbai',
|
|
||||||
56: 'BSC Mainnet',
|
|
||||||
97: 'BSC Testnet',
|
|
||||||
42161: 'Arbitrum One',
|
|
||||||
421614: 'Arbitrum Sepolia',
|
|
||||||
10: 'Optimism',
|
|
||||||
11155420: 'Optimism Sepolia',
|
|
||||||
8453: 'Base',
|
|
||||||
84532: 'Base Sepolia'
|
|
||||||
};
|
|
||||||
modulesInfo.networks[i].networkName = networkNames[Number(network.chainId)] || `Chain ID ${Number(network.chainId)}`;
|
|
||||||
|
|
||||||
console.log(`[MULTI_DBG] Сеть ${i + 1}: chainId=${Number(network.chainId)}, name=${modulesInfo.networks[i].networkName}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MULTI_DBG] Ошибка получения chainId для сети ${i + 1}:`, error.message);
|
|
||||||
modulesInfo.networks[i].chainId = null;
|
|
||||||
modulesInfo.networks[i].networkName = `Сеть ${i + 1}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем директорию temp если её нет
|
|
||||||
const tempDir = path.join(__dirname, '../temp');
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const deployResultPath = path.join(tempDir, `modules-${uniqueAddresses[0].toLowerCase()}.json`);
|
|
||||||
fs.writeFileSync(deployResultPath, JSON.stringify(modulesInfo, null, 2));
|
|
||||||
console.log(`[MULTI_DBG] Modules info saved to: ${deployResultPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This software is proprietary and confidential.
|
|
||||||
* Unauthorized copying, modification, or distribution is prohibited.
|
|
||||||
*
|
|
||||||
* For licensing inquiries: info@hb3-accelerator.com
|
|
||||||
* Website: https://hb3-accelerator.com
|
|
||||||
* GitHub: https://github.com/HB3-ACCELERATOR
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const dleV2Service = require('../services/dleV2Service');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const auth = require('../middleware/auth');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const ethers = require('ethers'); // Added ethers for private key validation
|
|
||||||
const create2 = require('../utils/create2');
|
|
||||||
const verificationStore = require('../services/verificationStore');
|
|
||||||
const etherscanV2 = require('../services/etherscanV2VerificationService');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route POST /api/dle-v2
|
|
||||||
* @desc Создать новое DLE v2 (Digital Legal Entity)
|
|
||||||
* @access Private (только для авторизованных пользователей с ролью admin)
|
|
||||||
*/
|
|
||||||
router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const dleParams = req.body;
|
|
||||||
logger.info('Получен запрос на создание DLE v2:', dleParams);
|
|
||||||
|
|
||||||
// Если параметр initialPartners не был передан явно, используем адрес авторизованного пользователя
|
|
||||||
if (!dleParams.initialPartners || dleParams.initialPartners.length === 0) {
|
|
||||||
// Проверяем, есть ли в сессии адрес кошелька пользователя
|
|
||||||
if (!req.user || !req.user.walletAddress) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Не указан адрес кошелька пользователя или партнеров для распределения токенов'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Используем адрес авторизованного пользователя
|
|
||||||
dleParams.initialPartners = [req.user.address || req.user.walletAddress];
|
|
||||||
|
|
||||||
// Если суммы не указаны, используем значение по умолчанию (100% токенов)
|
|
||||||
if (!dleParams.initialAmounts || dleParams.initialAmounts.length === 0) {
|
|
||||||
dleParams.initialAmounts = ['1000000000000000000000000']; // 1,000,000 токенов
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем DLE v2
|
|
||||||
const result = await dleV2Service.createDLE(dleParams);
|
|
||||||
|
|
||||||
logger.info('DLE v2 успешно создано:', result);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'DLE v2 успешно создано',
|
|
||||||
data: result.data
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при создании DLE v2:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при создании DLE v2'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route GET /api/dle-v2
|
|
||||||
* @desc Получить список всех DLE v2
|
|
||||||
* @access Public (доступно всем пользователям)
|
|
||||||
*/
|
|
||||||
router.get('/', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const dles = dleV2Service.getAllDLEs();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: dles
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при получении списка DLE v2:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при получении списка DLE v2'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route GET /api/dle-v2/default-params
|
|
||||||
* @desc Получить параметры по умолчанию для создания DLE v2
|
|
||||||
* @access Private
|
|
||||||
*/
|
|
||||||
router.get('/default-params', auth.requireAuth, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const defaultParams = {
|
|
||||||
name: '',
|
|
||||||
symbol: '',
|
|
||||||
location: '',
|
|
||||||
coordinates: '',
|
|
||||||
jurisdiction: 1,
|
|
||||||
oktmo: 45000000000,
|
|
||||||
okvedCodes: [],
|
|
||||||
kpp: 770101001,
|
|
||||||
quorumPercentage: 51,
|
|
||||||
initialPartners: [],
|
|
||||||
initialAmounts: [],
|
|
||||||
supportedChainIds: [1, 137, 56, 42161],
|
|
||||||
currentChainId: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: defaultParams
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при получении параметров по умолчанию:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при получении параметров по умолчанию'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route DELETE /api/dle-v2/:dleAddress
|
|
||||||
* @desc Удалить DLE v2 по адресу
|
|
||||||
* @access Private (только для авторизованных пользователей с ролью admin)
|
|
||||||
*/
|
|
||||||
router.delete('/:dleAddress', auth.requireAuth, auth.requireAdmin, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { dleAddress } = req.params;
|
|
||||||
logger.info(`Получен запрос на удаление DLE v2 с адресом: ${dleAddress}`);
|
|
||||||
|
|
||||||
// Проверяем существование DLE v2 в директории contracts-data/dles
|
|
||||||
const dlesDir = path.join(__dirname, '../contracts-data/dles');
|
|
||||||
const files = fs.readdirSync(dlesDir);
|
|
||||||
|
|
||||||
let fileToDelete = null;
|
|
||||||
|
|
||||||
// Находим файл, содержащий указанный адрес DLE
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.includes('dle-v2-') && file.endsWith('.json')) {
|
|
||||||
const filePath = path.join(dlesDir, file);
|
|
||||||
if (fs.statSync(filePath).isFile()) {
|
|
||||||
try {
|
|
||||||
const dleData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
||||||
if (dleData.dleAddress && dleData.dleAddress.toLowerCase() === dleAddress.toLowerCase()) {
|
|
||||||
fileToDelete = filePath;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Ошибка при чтении файла ${file}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileToDelete) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: `DLE v2 с адресом ${dleAddress} не найдено`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем файл
|
|
||||||
fs.unlinkSync(fileToDelete);
|
|
||||||
|
|
||||||
logger.info(`DLE v2 с адресом ${dleAddress} успешно удалено`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `DLE v2 с адресом ${dleAddress} успешно удалено`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при удалении DLE v2:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при удалении DLE v2'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route GET /api/dle-v2/check-admin-tokens
|
|
||||||
* @desc Проверить баланс админских токенов для адреса
|
|
||||||
* @access Public
|
|
||||||
*/
|
|
||||||
router.get('/check-admin-tokens', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { address } = req.query;
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Адрес кошелька не передан'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем баланс токенов
|
|
||||||
const { checkAdminRole } = require('../services/admin-role');
|
|
||||||
const isAdmin = await checkAdminRole(address);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
isAdmin: isAdmin,
|
|
||||||
address: address,
|
|
||||||
message: isAdmin ? 'Админские токены найдены' : 'Админские токены не найдены'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при проверке админских токенов:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при проверке админских токенов'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @route POST /api/dle-v2/validate-private-key
|
|
||||||
* @desc Валидировать приватный ключ и получить адрес кошелька
|
|
||||||
* @access Public
|
|
||||||
*/
|
|
||||||
router.post('/validate-private-key', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { privateKey } = req.body;
|
|
||||||
|
|
||||||
if (!privateKey) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Приватный ключ не передан'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логируем входящий ключ (только для отладки)
|
|
||||||
logger.info('Получен приватный ключ для валидации:', privateKey);
|
|
||||||
logger.info('Длина входящего ключа:', privateKey.length);
|
|
||||||
logger.info('Тип входящего ключа:', typeof privateKey);
|
|
||||||
logger.info('Полный объект запроса:', JSON.stringify(req.body));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Очищаем ключ от префикса 0x если есть
|
|
||||||
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey;
|
|
||||||
|
|
||||||
// Логируем очищенный ключ (только для отладки)
|
|
||||||
logger.info('Очищенный ключ:', cleanKey);
|
|
||||||
logger.info('Длина очищенного ключа:', cleanKey.length);
|
|
||||||
|
|
||||||
// Проверяем длину и формат (64 символа в hex)
|
|
||||||
if (cleanKey.length !== 64 || !/^[a-fA-F0-9]+$/.test(cleanKey)) {
|
|
||||||
logger.error('Некорректный формат ключа. Длина:', cleanKey.length, 'Формат:', /^[a-fA-F0-9]+$/.test(cleanKey));
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Некорректный формат приватного ключа'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем адрес из приватного ключа
|
|
||||||
const wallet = new ethers.Wallet('0x' + cleanKey);
|
|
||||||
const address = wallet.address;
|
|
||||||
|
|
||||||
// Логируем сгенерированный адрес
|
|
||||||
logger.info('Сгенерированный адрес из приватного ключа:', address);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
isValid: true,
|
|
||||||
address: address,
|
|
||||||
error: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при генерации адреса из приватного ключа:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Некорректный приватный ключ'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при валидации приватного ключа:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Произошла ошибка при валидации приватного ключа'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Дополнительные маршруты (подключаются из app.js)
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
// Сохранить GUID верификации (если нужно отдельным вызовом)
|
|
||||||
router.post('/verify/save-guid', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { address, chainId, guid } = req.body || {};
|
|
||||||
if (!address || !chainId || !guid) return res.status(400).json({ success: false, message: 'address, chainId, guid обязательны' });
|
|
||||||
const data = verificationStore.updateChain(address, chainId, { guid, status: 'submitted' });
|
|
||||||
return res.json({ success: true, data });
|
|
||||||
} catch (e) {
|
|
||||||
return res.status(500).json({ success: false, message: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Получить статусы верификации по адресу DLE
|
|
||||||
router.get('/verify/status/:address', auth.requireAuth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { address } = req.params;
|
|
||||||
const data = verificationStore.read(address);
|
|
||||||
return res.json({ success: true, data });
|
|
||||||
} catch (e) {
|
|
||||||
return res.status(500).json({ success: false, message: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обновить статусы верификации, опросив Etherscan V2
|
|
||||||
router.post('/verify/refresh/:address', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { address } = req.params;
|
|
||||||
let { etherscanApiKey } = req.body || {};
|
|
||||||
if (!etherscanApiKey) {
|
|
||||||
try {
|
|
||||||
const { getSecret } = require('../services/secretStore');
|
|
||||||
etherscanApiKey = await getSecret('ETHERSCAN_V2_API_KEY');
|
|
||||||
} catch(_) {}
|
|
||||||
}
|
|
||||||
const data = verificationStore.read(address);
|
|
||||||
if (!data || !data.chains) return res.json({ success: true, data });
|
|
||||||
|
|
||||||
// Если guid отсутствует или ранее была ошибка chainid — попробуем автоматически переотправить верификацию (resubmit)
|
|
||||||
const needResubmit = Object.values(data.chains).some(c => !c.guid || /Missing or unsupported chainid/i.test(c.status || ''));
|
|
||||||
if (needResubmit && etherscanApiKey) {
|
|
||||||
// Найти карточку DLE
|
|
||||||
const list = dleV2Service.getAllDLEs();
|
|
||||||
const card = list.find(x => x?.dleAddress && x.dleAddress.toLowerCase() === address.toLowerCase());
|
|
||||||
if (card) {
|
|
||||||
const deployParams = {
|
|
||||||
name: card.name,
|
|
||||||
symbol: card.symbol,
|
|
||||||
location: card.location,
|
|
||||||
coordinates: card.coordinates,
|
|
||||||
jurisdiction: card.jurisdiction,
|
|
||||||
oktmo: card.oktmo,
|
|
||||||
okvedCodes: Array.isArray(card.okvedCodes) ? card.okvedCodes : [],
|
|
||||||
kpp: card.kpp,
|
|
||||||
quorumPercentage: card.quorumPercentage,
|
|
||||||
initialPartners: Array.isArray(card.initialPartners) ? card.initialPartners : [],
|
|
||||||
initialAmounts: Array.isArray(card.initialAmounts) ? card.initialAmounts : [],
|
|
||||||
supportedChainIds: Array.isArray(card.networks) ? card.networks.map(n => n.chainId).filter(Boolean) : (card.governanceSettings?.supportedChainIds || []),
|
|
||||||
currentChainId: card.governanceSettings?.currentChainId || (Array.isArray(card.networks) && card.networks[0]?.chainId) || 1
|
|
||||||
};
|
|
||||||
const deployResult = { success: true, data: { dleAddress: card.dleAddress, networks: card.networks || [] } };
|
|
||||||
try {
|
|
||||||
await dleV2Service.autoVerifyAcrossChains({ deployParams, deployResult, apiKey: etherscanApiKey });
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Далее — обычный опрос по имеющимся guid
|
|
||||||
const latest = verificationStore.read(address);
|
|
||||||
const chains = Object.values(latest.chains);
|
|
||||||
for (const c of chains) {
|
|
||||||
if (!c.guid || !c.chainId) continue;
|
|
||||||
try {
|
|
||||||
const st = await etherscanV2.checkStatus(c.chainId, c.guid, etherscanApiKey);
|
|
||||||
verificationStore.updateChain(address, c.chainId, { status: st?.result || st?.message || 'unknown' });
|
|
||||||
} catch (e) {
|
|
||||||
verificationStore.updateChain(address, c.chainId, { status: `error: ${e.message}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updated = verificationStore.read(address);
|
|
||||||
return res.json({ success: true, data: updated });
|
|
||||||
} catch (e) {
|
|
||||||
return res.status(500).json({ success: false, message: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Повторно отправить верификацию на Etherscan V2 для уже созданного DLE
|
|
||||||
router.post('/verify/resubmit/:address', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { address } = req.params;
|
|
||||||
const { etherscanApiKey } = req.body || {};
|
|
||||||
if (!etherscanApiKey && !process.env.ETHERSCAN_API_KEY) {
|
|
||||||
return res.status(400).json({ success: false, message: 'etherscanApiKey обязателен' });
|
|
||||||
}
|
|
||||||
// Найти карточку DLE по адресу
|
|
||||||
const list = dleV2Service.getAllDLEs();
|
|
||||||
const card = list.find(x => x?.dleAddress && x.dleAddress.toLowerCase() === address.toLowerCase());
|
|
||||||
if (!card) return res.status(404).json({ success: false, message: 'Карточка DLE не найдена' });
|
|
||||||
|
|
||||||
// Сформировать deployParams из карточки
|
|
||||||
const deployParams = {
|
|
||||||
name: card.name,
|
|
||||||
symbol: card.symbol,
|
|
||||||
location: card.location,
|
|
||||||
coordinates: card.coordinates,
|
|
||||||
jurisdiction: card.jurisdiction,
|
|
||||||
oktmo: card.oktmo,
|
|
||||||
okvedCodes: Array.isArray(card.okvedCodes) ? card.okvedCodes : [],
|
|
||||||
kpp: card.kpp,
|
|
||||||
quorumPercentage: card.quorumPercentage,
|
|
||||||
initialPartners: Array.isArray(card.initialPartners) ? card.initialPartners : [],
|
|
||||||
initialAmounts: Array.isArray(card.initialAmounts) ? card.initialAmounts : [],
|
|
||||||
supportedChainIds: Array.isArray(card.networks) ? card.networks.map(n => n.chainId).filter(Boolean) : (card.governanceSettings?.supportedChainIds || []),
|
|
||||||
currentChainId: card.governanceSettings?.currentChainId || (Array.isArray(card.networks) && card.networks[0]?.chainId) || 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Сформировать deployResult из карточки
|
|
||||||
const deployResult = { success: true, data: { dleAddress: card.dleAddress, networks: card.networks || [] } };
|
|
||||||
|
|
||||||
await dleV2Service.autoVerifyAcrossChains({ deployParams, deployResult, apiKey: etherscanApiKey });
|
|
||||||
const updated = verificationStore.read(address);
|
|
||||||
return res.json({ success: true, data: updated });
|
|
||||||
} catch (e) {
|
|
||||||
return res.status(500).json({ success: false, message: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Предварительная проверка балансов во всех выбранных сетях
|
|
||||||
router.post('/precheck', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { supportedChainIds, privateKey } = req.body || {};
|
|
||||||
if (!privateKey) return res.status(400).json({ success: false, message: 'Приватный ключ не передан' });
|
|
||||||
if (!Array.isArray(supportedChainIds) || supportedChainIds.length === 0) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Не переданы сети для проверки' });
|
|
||||||
}
|
|
||||||
const result = await dleV2Service.checkBalances(supportedChainIds, privateKey);
|
|
||||||
return res.json({ success: true, data: result });
|
|
||||||
} catch (e) {
|
|
||||||
return res.status(500).json({ success: false, message: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,971 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This software is proprietary and confidential.
|
|
||||||
* Unauthorized copying, modification, or distribution is prohibited.
|
|
||||||
*
|
|
||||||
* For licensing inquiries: info@hb3-accelerator.com
|
|
||||||
* Website: https://hb3-accelerator.com
|
|
||||||
* GitHub: https://github.com/HB3-ACCELERATOR
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { ethers } = require('ethers');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const { getRpcUrlByChainId } = require('./rpcProviderService');
|
|
||||||
const etherscanV2 = require('./etherscanV2VerificationService');
|
|
||||||
const verificationStore = require('./verificationStore');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сервис для управления DLE v2 (Digital Legal Entity)
|
|
||||||
* Современный подход с единым контрактом
|
|
||||||
*/
|
|
||||||
class DLEV2Service {
|
|
||||||
/**
|
|
||||||
* Создает новое DLE v2 с заданными параметрами
|
|
||||||
* @param {Object} dleParams - Параметры DLE
|
|
||||||
* @returns {Promise<Object>} - Результат создания DLE
|
|
||||||
*/
|
|
||||||
async createDLE(dleParams) {
|
|
||||||
console.log("🔥 [DLEV2-SERVICE] ФУНКЦИЯ createDLE ВЫЗВАНА!");
|
|
||||||
logger.info("🚀 DEBUG: ВХОДИМ В createDLE ФУНКЦИЮ");
|
|
||||||
let paramsFile = null;
|
|
||||||
let tempParamsFile = null;
|
|
||||||
try {
|
|
||||||
logger.info('Начало создания DLE v2 с параметрами:', dleParams);
|
|
||||||
|
|
||||||
// Валидация входных данных
|
|
||||||
this.validateDLEParams(dleParams);
|
|
||||||
|
|
||||||
// Подготовка параметров для деплоя
|
|
||||||
const deployParams = this.prepareDeployParams(dleParams);
|
|
||||||
|
|
||||||
// Вычисляем адрес инициализатора (инициализатором является деплоер из переданного приватного ключа)
|
|
||||||
try {
|
|
||||||
const normalizedPk = dleParams.privateKey?.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`;
|
|
||||||
const initializerAddress = new ethers.Wallet(normalizedPk).address;
|
|
||||||
deployParams.initializerAddress = initializerAddress;
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Не удалось вычислить initializerAddress из приватного ключа:', e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем одноразовый CREATE2_SALT и сохраняем его с уникальным ключом в secrets
|
|
||||||
const { createAndStoreNewCreate2Salt } = require('./secretStore');
|
|
||||||
const { salt: create2Salt, key: saltKey } = await createAndStoreNewCreate2Salt({ label: deployParams.name || 'DLEv2' });
|
|
||||||
logger.info(`CREATE2_SALT создан и сохранён: key=${saltKey}`);
|
|
||||||
|
|
||||||
// Сохраняем параметры во временный файл
|
|
||||||
paramsFile = this.saveParamsToFile(deployParams);
|
|
||||||
|
|
||||||
// Копируем параметры во временный файл с предсказуемым именем
|
|
||||||
tempParamsFile = path.join(__dirname, '../scripts/deploy/current-params.json');
|
|
||||||
const deployDir = path.dirname(tempParamsFile);
|
|
||||||
if (!fs.existsSync(deployDir)) {
|
|
||||||
fs.mkdirSync(deployDir, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.copyFileSync(paramsFile, tempParamsFile);
|
|
||||||
|
|
||||||
// Готовим RPC для всех выбранных сетей
|
|
||||||
const rpcUrls = [];
|
|
||||||
for (const cid of deployParams.supportedChainIds) {
|
|
||||||
logger.info(`Поиск RPC URL для chain_id: ${cid}`);
|
|
||||||
const ru = await getRpcUrlByChainId(cid);
|
|
||||||
if (!ru) {
|
|
||||||
throw new Error(`RPC URL для сети с chain_id ${cid} не найден в базе данных`);
|
|
||||||
}
|
|
||||||
rpcUrls.push(ru);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем CREATE2_SALT, RPC_URLS и initializer в файл параметров
|
|
||||||
const currentParams = JSON.parse(fs.readFileSync(tempParamsFile, 'utf8'));
|
|
||||||
// Копируем все параметры из deployParams
|
|
||||||
Object.assign(currentParams, deployParams);
|
|
||||||
currentParams.CREATE2_SALT = create2Salt;
|
|
||||||
currentParams.rpcUrls = rpcUrls;
|
|
||||||
currentParams.currentChainId = deployParams.currentChainId || deployParams.supportedChainIds[0];
|
|
||||||
const { ethers } = require('ethers');
|
|
||||||
currentParams.initializer = dleParams.privateKey ? new ethers.Wallet(dleParams.privateKey.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`).address : "0x0000000000000000000000000000000000000000";
|
|
||||||
fs.writeFileSync(tempParamsFile, JSON.stringify(currentParams, null, 2));
|
|
||||||
|
|
||||||
logger.info(`Файл параметров скопирован и обновлен с CREATE2_SALT`);
|
|
||||||
|
|
||||||
// Лёгкая проверка баланса в первой сети
|
|
||||||
{
|
|
||||||
const { ethers } = require('ethers');
|
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrls[0]);
|
|
||||||
if (dleParams.privateKey) {
|
|
||||||
const pk = dleParams.privateKey.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`;
|
|
||||||
const walletAddress = new ethers.Wallet(pk, provider).address;
|
|
||||||
const balance = await provider.getBalance(walletAddress);
|
|
||||||
|
|
||||||
const minBalance = ethers.parseEther("0.00001");
|
|
||||||
logger.info(`Баланс кошелька ${walletAddress}: ${ethers.formatEther(balance)} ETH`);
|
|
||||||
if (balance < minBalance) {
|
|
||||||
throw new Error(`Недостаточно ETH для деплоя в ${deployParams.supportedChainIds[0]}. Баланс: ${ethers.formatEther(balance)} ETH`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!dleParams.privateKey) {
|
|
||||||
throw new Error('Приватный ключ для деплоя не передан');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сначала компилируем контракты
|
|
||||||
logger.info("🔨 Компилируем контракты перед вычислением INIT_CODE_HASH...");
|
|
||||||
try {
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const compile = spawn('npx', ['hardhat', 'compile'], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: 'inherit'
|
|
||||||
});
|
|
||||||
|
|
||||||
compile.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
logger.info('✅ Контракты скомпилированы успешно');
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
logger.warn(`⚠️ Компиляция завершилась с кодом: ${code}`);
|
|
||||||
resolve(); // Продолжаем даже при ошибке компиляции
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
compile.on('error', (error) => {
|
|
||||||
logger.warn('⚠️ Ошибка компиляции:', error.message);
|
|
||||||
resolve(); // Продолжаем даже при ошибке
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (compileError) {
|
|
||||||
logger.warn('⚠️ Ошибка компиляции:', compileError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// INIT_CODE_HASH будет вычислен в deploy-multichain.js
|
|
||||||
|
|
||||||
// Factory больше не используется - деплой DLE напрямую
|
|
||||||
logger.info(`Подготовка к прямому деплою DLE в сетях: ${deployParams.supportedChainIds.join(', ')}`);
|
|
||||||
|
|
||||||
// Мультисетевой деплой одним вызовом
|
|
||||||
logger.info('Запуск мульти-чейн деплоя...');
|
|
||||||
logger.info("🔍 DEBUG: Подготовка к прямому деплою...");
|
|
||||||
|
|
||||||
const result = await this.runDeployMultichain(paramsFile, {
|
|
||||||
rpcUrls: rpcUrls,
|
|
||||||
chainIds: deployParams.supportedChainIds,
|
|
||||||
privateKey: dleParams.privateKey?.startsWith('0x') ? dleParams.privateKey : `0x${dleParams.privateKey}`,
|
|
||||||
salt: create2Salt
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Деплой завершен, результат:', JSON.stringify(result, null, 2));
|
|
||||||
logger.info("🔍 DEBUG: Запуск мультисетевого деплоя...");
|
|
||||||
|
|
||||||
// Сохраняем информацию о созданном DLE для отображения на странице управления
|
|
||||||
try {
|
|
||||||
logger.info('Результат деплоя для сохранения:', JSON.stringify(result, null, 2));
|
|
||||||
|
|
||||||
// Проверяем структуру результата
|
|
||||||
if (!result || typeof result !== 'object') {
|
|
||||||
logger.error('Неверная структура результата деплоя:', result);
|
|
||||||
throw new Error('Неверная структура результата деплоя');
|
|
||||||
}
|
|
||||||
logger.info("🔍 DEBUG: Вызываем runDeployMultichain...");
|
|
||||||
|
|
||||||
// Если результат - массив (прямой результат из скрипта), преобразуем его
|
|
||||||
let deployResult = result;
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
logger.info('Результат - массив, преобразуем в объект');
|
|
||||||
const addresses = result.map(r => r.address);
|
|
||||||
const allSame = addresses.every(addr => addr.toLowerCase() === addresses[0].toLowerCase());
|
|
||||||
deployResult = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
dleAddress: addresses[0],
|
|
||||||
networks: result.map((r, index) => ({
|
|
||||||
chainId: r.chainId,
|
|
||||||
address: r.address,
|
|
||||||
success: true
|
|
||||||
})),
|
|
||||||
allSame
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstNet = Array.isArray(deployResult?.data?.networks) && deployResult.data.networks.length > 0 ? deployResult.data.networks[0] : null;
|
|
||||||
const dleData = {
|
|
||||||
name: deployParams.name,
|
|
||||||
symbol: deployParams.symbol,
|
|
||||||
location: deployParams.location,
|
|
||||||
coordinates: deployParams.coordinates,
|
|
||||||
jurisdiction: deployParams.jurisdiction,
|
|
||||||
okvedCodes: deployParams.okvedCodes || [],
|
|
||||||
kpp: deployParams.kpp,
|
|
||||||
quorumPercentage: deployParams.quorumPercentage,
|
|
||||||
initialPartners: deployParams.initialPartners || [],
|
|
||||||
initialAmounts: deployParams.initialAmounts || [],
|
|
||||||
governanceSettings: {
|
|
||||||
quorumPercentage: deployParams.quorumPercentage,
|
|
||||||
supportedChainIds: deployParams.supportedChainIds,
|
|
||||||
currentChainId: deployParams.currentChainId
|
|
||||||
},
|
|
||||||
dleAddress: (deployResult?.data?.dleAddress) || (firstNet?.address) || null,
|
|
||||||
version: 'v2',
|
|
||||||
networks: deployResult?.data?.networks || [],
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// logger.info('Данные DLE для сохранения:', JSON.stringify(dleData, null, 2)); // Убрано избыточное логирование
|
|
||||||
|
|
||||||
if (dleData.dleAddress) {
|
|
||||||
// Сохраняем данные DLE в файл
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const fileName = `dle-v2-${timestamp}.json`;
|
|
||||||
const savedPath = path.join(__dirname, '../contracts-data/dles', fileName);
|
|
||||||
|
|
||||||
// Создаем директорию, если её нет
|
|
||||||
const dlesDir = path.dirname(savedPath);
|
|
||||||
if (!fs.existsSync(dlesDir)) {
|
|
||||||
fs.mkdirSync(dlesDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(savedPath, JSON.stringify(dleData, null, 2));
|
|
||||||
// logger.info(`DLE данные сохранены в: ${savedPath}`); // Убрано избыточное логирование
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: dleData
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error('DLE адрес не получен после деплоя');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Не удалось сохранить локальную карточку DLE:', e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем ключ Etherscan V2 для последующих авто‑обновлений статуса, если он передан
|
|
||||||
try {
|
|
||||||
if (dleParams.etherscanApiKey) {
|
|
||||||
const { setSecret } = require('./secretStore');
|
|
||||||
await setSecret('ETHERSCAN_V2_API_KEY', dleParams.etherscanApiKey);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// Верификация выполняется в deploy-multichain.js
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при создании DLE v2:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (paramsFile || tempParamsFile) {
|
|
||||||
this.cleanupTempFiles(paramsFile, tempParamsFile);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Ошибка при очистке временных файлов (finally):', e.message);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.pruneOldTempFiles(24 * 60 * 60 * 1000);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Ошибка при автоочистке старых временных файлов:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Валидирует параметры DLE
|
|
||||||
* @param {Object} params - Параметры для валидации
|
|
||||||
*/
|
|
||||||
validateDLEParams(params) {
|
|
||||||
if (!params.name || params.name.trim() === '') {
|
|
||||||
throw new Error('Название DLE обязательно');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.symbol || params.symbol.trim() === '') {
|
|
||||||
throw new Error('Символ токена обязателен');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.location || params.location.trim() === '') {
|
|
||||||
throw new Error('Местонахождение DLE обязательно');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.initialPartners || !Array.isArray(params.initialPartners)) {
|
|
||||||
throw new Error('Партнеры должны быть массивом');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.initialAmounts || !Array.isArray(params.initialAmounts)) {
|
|
||||||
throw new Error('Суммы должны быть массивом');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.initialPartners.length !== params.initialAmounts.length) {
|
|
||||||
throw new Error('Количество партнеров должно соответствовать количеству сумм распределения');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.initialPartners.length === 0) {
|
|
||||||
throw new Error('Должен быть указан хотя бы один партнер');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.quorumPercentage > 100 || params.quorumPercentage < 1) {
|
|
||||||
throw new Error('Процент кворума должен быть от 1% до 100%');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем адреса партнеров
|
|
||||||
for (let i = 0; i < params.initialPartners.length; i++) {
|
|
||||||
if (!ethers.isAddress || !ethers.isAddress(params.initialPartners[i])) {
|
|
||||||
throw new Error(`Неверный адрес партнера ${i + 1}: ${params.initialPartners[i]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что выбраны сети
|
|
||||||
if (!params.supportedChainIds || !Array.isArray(params.supportedChainIds) || params.supportedChainIds.length === 0) {
|
|
||||||
throw new Error('Должна быть выбрана хотя бы одна сеть для деплоя');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Дополнительные проверки безопасности
|
|
||||||
if (params.name.length > 100) {
|
|
||||||
throw new Error('Название DLE слишком длинное (максимум 100 символов)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.symbol.length > 10) {
|
|
||||||
throw new Error('Символ токена слишком длинный (максимум 10 символов)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.location.length > 200) {
|
|
||||||
throw new Error('Местонахождение слишком длинное (максимум 200 символов)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем суммы токенов
|
|
||||||
for (let i = 0; i < params.initialAmounts.length; i++) {
|
|
||||||
const amount = params.initialAmounts[i];
|
|
||||||
if (typeof amount !== 'string' && typeof amount !== 'number') {
|
|
||||||
throw new Error(`Неверный тип суммы для партнера ${i + 1}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
||||||
if (isNaN(numAmount) || numAmount <= 0) {
|
|
||||||
throw new Error(`Неверная сумма для партнера ${i + 1}: ${amount}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем приватный ключ
|
|
||||||
if (!params.privateKey) {
|
|
||||||
throw new Error('Приватный ключ обязателен для деплоя');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pk = params.privateKey.startsWith('0x') ? params.privateKey : `0x${params.privateKey}`;
|
|
||||||
if (!/^0x[a-fA-F0-9]{64}$/.test(pk)) {
|
|
||||||
throw new Error('Неверный формат приватного ключа');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что не деплоим в mainnet без подтверждения
|
|
||||||
const mainnetChains = [1, 137, 56, 42161]; // Ethereum, Polygon, BSC, Arbitrum
|
|
||||||
const hasMainnet = params.supportedChainIds.some(id => mainnetChains.includes(id));
|
|
||||||
|
|
||||||
if (hasMainnet) {
|
|
||||||
logger.warn('⚠️ ВНИМАНИЕ: Деплой включает mainnet сети! Убедитесь, что это необходимо.');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('✅ Валидация параметров DLE пройдена успешно');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохраняет/обновляет локальную карточку DLE для отображения в UI
|
|
||||||
* @param {Object} dleData
|
|
||||||
* @returns {string} Путь к сохраненному файлу
|
|
||||||
*/
|
|
||||||
saveDLEData(dleData) {
|
|
||||||
try {
|
|
||||||
if (!dleData || !dleData.dleAddress) {
|
|
||||||
throw new Error('Неверные данные для сохранения карточки DLE: отсутствует dleAddress');
|
|
||||||
}
|
|
||||||
const dlesDir = path.join(__dirname, '../contracts-data/dles');
|
|
||||||
if (!fs.existsSync(dlesDir)) {
|
|
||||||
fs.mkdirSync(dlesDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если уже есть файл с таким адресом — обновим его
|
|
||||||
let targetFile = null;
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(dlesDir);
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.endsWith('.json') && file.includes('dle-v2-')) {
|
|
||||||
const fp = path.join(dlesDir, file);
|
|
||||||
try {
|
|
||||||
const existing = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
||||||
if (existing?.dleAddress && existing.dleAddress.toLowerCase() === dleData.dleAddress.toLowerCase()) {
|
|
||||||
targetFile = fp;
|
|
||||||
// Совмещаем данные (не удаляя существующие поля сетей/верификации, если присутствуют)
|
|
||||||
dleData = { ...existing, ...dleData };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
if (!targetFile) {
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const fileName = `dle-v2-${ts}.json`;
|
|
||||||
targetFile = path.join(dlesDir, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(targetFile, JSON.stringify(dleData, null, 2));
|
|
||||||
logger.info(`Карточка DLE сохранена: ${targetFile}`);
|
|
||||||
return targetFile;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Ошибка сохранения карточки DLE:', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Подготавливает параметры для деплоя
|
|
||||||
* @param {Object} params - Параметры DLE из формы
|
|
||||||
* @returns {Object} - Подготовленные параметры для скрипта деплоя
|
|
||||||
*/
|
|
||||||
prepareDeployParams(params) {
|
|
||||||
// Создаем копию объекта, чтобы не изменять исходный
|
|
||||||
const deployParams = { ...params };
|
|
||||||
|
|
||||||
// Преобразуем суммы из строк или чисел в BigNumber, если нужно
|
|
||||||
if (deployParams.initialAmounts && Array.isArray(deployParams.initialAmounts)) {
|
|
||||||
deployParams.initialAmounts = deployParams.initialAmounts.map(rawAmount => {
|
|
||||||
// Принимаем как строки, так и числа; конвертируем в base units (18 знаков)
|
|
||||||
try {
|
|
||||||
if (typeof rawAmount === 'number' && Number.isFinite(rawAmount)) {
|
|
||||||
return ethers.parseUnits(rawAmount.toString(), 18).toString();
|
|
||||||
}
|
|
||||||
if (typeof rawAmount === 'string') {
|
|
||||||
const a = rawAmount.trim();
|
|
||||||
if (a.startsWith('0x')) {
|
|
||||||
// Уже base units (hex BigNumber) — оставляем как есть
|
|
||||||
return BigInt(a).toString();
|
|
||||||
}
|
|
||||||
// Десятичная строка — конвертируем в base units
|
|
||||||
return ethers.parseUnits(a, 18).toString();
|
|
||||||
}
|
|
||||||
// BigInt или иные типы — приводим к строке без изменения масштаба
|
|
||||||
return rawAmount.toString();
|
|
||||||
} catch (e) {
|
|
||||||
// Фолбэк: безопасно привести к строке
|
|
||||||
return String(rawAmount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Убеждаемся, что okvedCodes - это массив
|
|
||||||
if (!Array.isArray(deployParams.okvedCodes)) {
|
|
||||||
deployParams.okvedCodes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Преобразуем kpp в число
|
|
||||||
if (deployParams.kpp) {
|
|
||||||
deployParams.kpp = parseInt(deployParams.kpp) || 0;
|
|
||||||
} else {
|
|
||||||
deployParams.kpp = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Убеждаемся, что supportedChainIds - это массив
|
|
||||||
if (!Array.isArray(deployParams.supportedChainIds)) {
|
|
||||||
deployParams.supportedChainIds = [1]; // По умолчанию Ethereum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем currentChainId как первую выбранную сеть
|
|
||||||
if (deployParams.supportedChainIds.length > 0) {
|
|
||||||
deployParams.currentChainId = deployParams.supportedChainIds[0];
|
|
||||||
} else {
|
|
||||||
deployParams.currentChainId = 1; // По умолчанию Ethereum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем logoURI
|
|
||||||
if (deployParams.logoURI) {
|
|
||||||
// Если logoURI относительный путь, делаем его абсолютным
|
|
||||||
if (deployParams.logoURI.startsWith('/uploads/')) {
|
|
||||||
deployParams.logoURI = `http://localhost:8000${deployParams.logoURI}`;
|
|
||||||
}
|
|
||||||
// Если это placeholder, оставляем как есть
|
|
||||||
if (deployParams.logoURI.includes('placeholder.com')) {
|
|
||||||
// Оставляем как есть
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deployParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохраняет параметры во временный файл
|
|
||||||
* @param {Object} params - Параметры для сохранения
|
|
||||||
* @returns {string} - Путь к сохраненному файлу
|
|
||||||
*/
|
|
||||||
saveParamsToFile(params) {
|
|
||||||
const tempDir = path.join(__dirname, '../temp');
|
|
||||||
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = `dle-v2-params-${Date.now()}.json`;
|
|
||||||
const filePath = path.join(tempDir, fileName);
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(params, null, 2));
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запускает скрипт деплоя DLE v2
|
|
||||||
* @param {string} paramsFile - Путь к файлу с параметрами
|
|
||||||
* @returns {Promise<Object>} - Результат деплоя
|
|
||||||
*/
|
|
||||||
runDeployScript(paramsFile, extraEnv = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptPath = path.join(__dirname, '../scripts/deploy/deploy-multichain.js');
|
|
||||||
if (!fs.existsSync(scriptPath)) {
|
|
||||||
reject(new Error('Скрипт деплоя DLE v2 не найден: ' + scriptPath));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVars = {
|
|
||||||
...process.env,
|
|
||||||
RPC_URL: extraEnv.rpcUrl,
|
|
||||||
PRIVATE_KEY: extraEnv.privateKey
|
|
||||||
};
|
|
||||||
|
|
||||||
const hardhatProcess = spawn('npx', ['hardhat', 'run', scriptPath], {
|
|
||||||
cwd: path.join(__dirname, '..'),
|
|
||||||
env: envVars,
|
|
||||||
stdio: ['inherit', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
hardhatProcess.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
logger.info(`[DLE v2 Deploy] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
hardhatProcess.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
logger.error(`[DLE v2 Deploy Error] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
hardhatProcess.on('close', (code) => {
|
|
||||||
try {
|
|
||||||
const result = this.extractDeployResult(stdout);
|
|
||||||
resolve(result);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при извлечении результатов деплоя DLE v2:', error);
|
|
||||||
if (code === 0) {
|
|
||||||
reject(new Error('Не удалось найти информацию о созданном DLE v2'));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Скрипт деплоя DLE v2 завершился с кодом ${code}: ${stderr}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
hardhatProcess.on('error', (error) => {
|
|
||||||
logger.error('Ошибка запуска скрипта деплоя DLE v2:', error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мультисетевой деплой
|
|
||||||
runDeployMultichain(paramsFile, opts = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptPath = path.join(__dirname, '../scripts/deploy/deploy-multichain.js');
|
|
||||||
if (!fs.existsSync(scriptPath)) return reject(new Error('Скрипт мультисетевого деплоя не найден: ' + scriptPath));
|
|
||||||
|
|
||||||
const envVars = {
|
|
||||||
...process.env,
|
|
||||||
PRIVATE_KEY: opts.privateKey
|
|
||||||
};
|
|
||||||
|
|
||||||
const p = spawn('npx', ['hardhat', 'run', scriptPath], {
|
|
||||||
cwd: path.join(__dirname, '..'),
|
|
||||||
env: envVars,
|
|
||||||
stdio: ['inherit', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '', stderr = '';
|
|
||||||
p.stdout.on('data', (d) => {
|
|
||||||
stdout += d.toString();
|
|
||||||
logger.info(`[MULTICHAIN_DEPLOY] ${d.toString().trim()}`);
|
|
||||||
});
|
|
||||||
p.stderr.on('data', (d) => {
|
|
||||||
stderr += d.toString();
|
|
||||||
logger.error(`[MULTICHAIN_DEPLOY_ERR] ${d.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
p.on('close', (code) => {
|
|
||||||
try {
|
|
||||||
// Ищем результат в формате MULTICHAIN_DEPLOY_RESULT
|
|
||||||
const resultMatch = stdout.match(/MULTICHAIN_DEPLOY_RESULT\s+(\[.*\])/);
|
|
||||||
|
|
||||||
if (resultMatch) {
|
|
||||||
const deployResults = JSON.parse(resultMatch[1]);
|
|
||||||
|
|
||||||
// Преобразуем результат в нужный формат
|
|
||||||
const addresses = deployResults.map(r => r.address);
|
|
||||||
const allSame = addresses.every(addr => addr.toLowerCase() === addresses[0].toLowerCase());
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
dleAddress: addresses[0],
|
|
||||||
networks: deployResults.map((r, index) => ({
|
|
||||||
chainId: r.chainId,
|
|
||||||
address: r.address,
|
|
||||||
success: true
|
|
||||||
})),
|
|
||||||
allSame
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: ищем адреса DLE в выводе по новому формату
|
|
||||||
const dleAddressMatches = stdout.match(/\[MULTI_DBG\] chainId=\d+ DLE deployed at=(0x[a-fA-F0-9]{40})/g);
|
|
||||||
if (!dleAddressMatches || dleAddressMatches.length === 0) {
|
|
||||||
throw new Error('Не найдены адреса DLE в выводе');
|
|
||||||
}
|
|
||||||
|
|
||||||
const addresses = dleAddressMatches.map(match => match.match(/(0x[a-fA-F0-9]{40})/)[1]);
|
|
||||||
const addr = addresses[0];
|
|
||||||
const allSame = addresses.every(x => x.toLowerCase() === addr.toLowerCase());
|
|
||||||
|
|
||||||
if (!allSame) {
|
|
||||||
logger.warn('Адреса отличаются между сетями — продолжаем, сохраню по-сеточно', { addresses });
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
dleAddress: addr,
|
|
||||||
networks: addresses.map((address, index) => ({
|
|
||||||
chainId: opts.chainIds[index] || index + 1,
|
|
||||||
address,
|
|
||||||
success: true
|
|
||||||
})),
|
|
||||||
allSame
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject(new Error(`Ошибка мультисетевого деплоя: ${e.message}\nSTDOUT:${stdout}\nSTDERR:${stderr}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
p.on('error', (e) => reject(e));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлекает результат деплоя из stdout
|
|
||||||
* @param {string} stdout - Вывод скрипта
|
|
||||||
* @returns {Object} - Результат деплоя
|
|
||||||
*/
|
|
||||||
extractDeployResult(stdout) {
|
|
||||||
// Ищем результат в формате MULTICHAIN_DEPLOY_RESULT
|
|
||||||
const resultMatch = stdout.match(/MULTICHAIN_DEPLOY_RESULT\s+(\[.*?\])/);
|
|
||||||
|
|
||||||
if (resultMatch) {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(resultMatch[1]);
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Ошибка парсинга JSON результата:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: ищем строки с адресами в выводе по новому формату
|
|
||||||
const dleAddressMatch = stdout.match(/\[MULTI_DBG\] chainId=\d+ DLE deployed at=(0x[a-fA-F0-9]{40})/);
|
|
||||||
|
|
||||||
if (dleAddressMatch) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
dleAddress: dleAddressMatch[1],
|
|
||||||
version: 'v2'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если не нашли адрес, выводим весь stdout для отладки
|
|
||||||
console.log('Полный вывод скрипта:', stdout);
|
|
||||||
throw new Error('Не удалось извлечь адрес DLE из вывода скрипта');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Очищает временные файлы
|
|
||||||
* @param {string} paramsFile - Путь к файлу параметров
|
|
||||||
* @param {string} tempParamsFile - Путь к временному файлу параметров
|
|
||||||
*/
|
|
||||||
cleanupTempFiles(paramsFile, tempParamsFile) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(paramsFile)) {
|
|
||||||
fs.unlinkSync(paramsFile);
|
|
||||||
}
|
|
||||||
if (fs.existsSync(tempParamsFile)) {
|
|
||||||
fs.unlinkSync(tempParamsFile);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Не удалось очистить временные файлы:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет временные файлы параметров деплоя старше заданного возраста
|
|
||||||
* @param {number} maxAgeMs - Макс. возраст файлов в миллисекундах (по умолчанию 24ч)
|
|
||||||
*/
|
|
||||||
pruneOldTempFiles(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
||||||
const tempDir = path.join(__dirname, '../temp');
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(tempDir)) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const files = fs.readdirSync(tempDir).filter(f => f.startsWith('dle-v2-params-') && f.endsWith('.json'));
|
|
||||||
for (const f of files) {
|
|
||||||
const fp = path.join(tempDir, f);
|
|
||||||
try {
|
|
||||||
const st = fs.statSync(fp);
|
|
||||||
if (now - st.mtimeMs > maxAgeMs) {
|
|
||||||
fs.unlinkSync(fp);
|
|
||||||
logger.info(`Удалён старый временный файл: ${fp}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Не удалось обработать файл ${fp}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Ошибка pruneOldTempFiles:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает список всех созданных DLE v2
|
|
||||||
* @returns {Array<Object>} - Список DLE v2
|
|
||||||
*/
|
|
||||||
getAllDLEs() {
|
|
||||||
try {
|
|
||||||
const dlesDir = path.join(__dirname, '../contracts-data/dles');
|
|
||||||
|
|
||||||
if (!fs.existsSync(dlesDir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(dlesDir);
|
|
||||||
const allDles = files
|
|
||||||
.filter(file => file.endsWith('.json') && file.includes('dle-v2-'))
|
|
||||||
.map(file => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(path.join(dlesDir, file), 'utf8'));
|
|
||||||
return { ...data, _fileName: file };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Ошибка при чтении файла ${file}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(dle => dle !== null);
|
|
||||||
|
|
||||||
// Группируем DLE по мультичейн деплоям
|
|
||||||
const groupedDles = this.groupMultichainDLEs(allDles);
|
|
||||||
|
|
||||||
return groupedDles;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Ошибка при получении списка DLE v2:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Группирует DLE по мультичейн деплоям
|
|
||||||
* @param {Array<Object>} allDles - Все DLE из файлов
|
|
||||||
* @returns {Array<Object>} - Сгруппированные DLE
|
|
||||||
*/
|
|
||||||
groupMultichainDLEs(allDles) {
|
|
||||||
const groups = new Map();
|
|
||||||
|
|
||||||
for (const dle of allDles) {
|
|
||||||
// Создаем ключ для группировки на основе общих параметров
|
|
||||||
const groupKey = this.createGroupKey(dle);
|
|
||||||
|
|
||||||
if (!groups.has(groupKey)) {
|
|
||||||
groups.set(groupKey, {
|
|
||||||
// Основные данные из первого DLE
|
|
||||||
name: dle.name,
|
|
||||||
symbol: dle.symbol,
|
|
||||||
location: dle.location,
|
|
||||||
coordinates: dle.coordinates,
|
|
||||||
jurisdiction: dle.jurisdiction,
|
|
||||||
oktmo: dle.oktmo,
|
|
||||||
okvedCodes: dle.okvedCodes,
|
|
||||||
kpp: dle.kpp,
|
|
||||||
quorumPercentage: dle.quorumPercentage,
|
|
||||||
version: dle.version || 'v2',
|
|
||||||
deployedMultichain: true,
|
|
||||||
// Мультичейн информация
|
|
||||||
networks: [],
|
|
||||||
// Модули (одинаковые во всех сетях)
|
|
||||||
modules: dle.modules,
|
|
||||||
// Время создания (самое раннее)
|
|
||||||
creationTimestamp: dle.creationTimestamp,
|
|
||||||
creationBlock: dle.creationBlock
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = groups.get(groupKey);
|
|
||||||
|
|
||||||
// Если у DLE есть массив networks, используем его
|
|
||||||
if (dle.networks && Array.isArray(dle.networks)) {
|
|
||||||
for (const network of dle.networks) {
|
|
||||||
group.networks.push({
|
|
||||||
chainId: network.chainId,
|
|
||||||
dleAddress: network.address || network.dleAddress,
|
|
||||||
factoryAddress: network.factoryAddress,
|
|
||||||
rpcUrl: network.rpcUrl || this.getRpcUrlForChain(network.chainId)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Старый формат: добавляем информацию о сети из корня DLE
|
|
||||||
group.networks.push({
|
|
||||||
chainId: dle.chainId,
|
|
||||||
dleAddress: dle.dleAddress,
|
|
||||||
factoryAddress: dle.factoryAddress,
|
|
||||||
rpcUrl: dle.rpcUrl || this.getRpcUrlForChain(dle.chainId)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем время создания на самое раннее
|
|
||||||
if (dle.creationTimestamp && (!group.creationTimestamp || dle.creationTimestamp < group.creationTimestamp)) {
|
|
||||||
group.creationTimestamp = dle.creationTimestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Преобразуем группы в массив
|
|
||||||
return Array.from(groups.values()).map(group => ({
|
|
||||||
...group,
|
|
||||||
// Основной адрес DLE (из первой сети)
|
|
||||||
dleAddress: group.networks[0]?.dleAddress,
|
|
||||||
// Общее количество сетей
|
|
||||||
totalNetworks: group.networks.length,
|
|
||||||
// Поддерживаемые сети
|
|
||||||
supportedChainIds: group.networks.map(n => n.chainId)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создает ключ для группировки DLE
|
|
||||||
* @param {Object} dle - Данные DLE
|
|
||||||
* @returns {string} - Ключ группировки
|
|
||||||
*/
|
|
||||||
createGroupKey(dle) {
|
|
||||||
// Группируем по основным параметрам DLE
|
|
||||||
const keyParts = [
|
|
||||||
dle.name,
|
|
||||||
dle.symbol,
|
|
||||||
dle.location,
|
|
||||||
dle.coordinates,
|
|
||||||
dle.jurisdiction,
|
|
||||||
dle.oktmo,
|
|
||||||
dle.kpp,
|
|
||||||
dle.quorumPercentage,
|
|
||||||
// Сортируем okvedCodes для стабильного ключа
|
|
||||||
Array.isArray(dle.okvedCodes) ? dle.okvedCodes.sort().join(',') : '',
|
|
||||||
// Сортируем supportedChainIds для стабильного ключа
|
|
||||||
Array.isArray(dle.supportedChainIds) ? dle.supportedChainIds.sort().join(',') : ''
|
|
||||||
];
|
|
||||||
|
|
||||||
return keyParts.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает RPC URL для сети
|
|
||||||
* @param {number} chainId - ID сети
|
|
||||||
* @returns {string|null} - RPC URL
|
|
||||||
*/
|
|
||||||
getRpcUrlForChain(chainId) {
|
|
||||||
try {
|
|
||||||
// Простая маппинг для основных сетей
|
|
||||||
const rpcMap = {
|
|
||||||
1: 'https://eth-mainnet.g.alchemy.com/v2/demo',
|
|
||||||
11155111: 'https://eth-sepolia.nodereal.io/v1/56dec8028bae4f26b76099a42dae2b52',
|
|
||||||
17000: 'https://ethereum-holesky.publicnode.com',
|
|
||||||
421614: 'https://sepolia-rollup.arbitrum.io/rpc',
|
|
||||||
84532: 'https://sepolia.base.org'
|
|
||||||
};
|
|
||||||
return rpcMap[chainId] || null;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет балансы в указанных сетях
|
|
||||||
* @param {number[]} chainIds - Массив chainId для проверки
|
|
||||||
* @param {string} privateKey - Приватный ключ
|
|
||||||
* @returns {Promise<Object>} - Результат проверки балансов
|
|
||||||
*/
|
|
||||||
async checkBalances(chainIds, privateKey) {
|
|
||||||
const { getRpcUrlByChainId } = require('./rpcProviderService');
|
|
||||||
const { ethers } = require('ethers');
|
|
||||||
const balances = [];
|
|
||||||
const insufficient = [];
|
|
||||||
|
|
||||||
for (const chainId of chainIds) {
|
|
||||||
try {
|
|
||||||
const rpcUrl = await getRpcUrlByChainId(chainId);
|
|
||||||
if (!rpcUrl) {
|
|
||||||
balances.push({
|
|
||||||
chainId,
|
|
||||||
balanceEth: '0',
|
|
||||||
ok: false,
|
|
||||||
error: 'RPC URL не найден'
|
|
||||||
});
|
|
||||||
insufficient.push(chainId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
||||||
const wallet = new ethers.Wallet(privateKey, provider);
|
|
||||||
const balance = await provider.getBalance(wallet.address);
|
|
||||||
|
|
||||||
const balanceEth = ethers.formatEther(balance);
|
|
||||||
const minBalance = ethers.parseEther("0.001");
|
|
||||||
const ok = balance >= minBalance;
|
|
||||||
|
|
||||||
balances.push({
|
|
||||||
chainId,
|
|
||||||
address: wallet.address,
|
|
||||||
balanceEth,
|
|
||||||
ok
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
insufficient.push(chainId);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
balances.push({
|
|
||||||
chainId,
|
|
||||||
balanceEth: '0',
|
|
||||||
ok: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
insufficient.push(chainId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
balances,
|
|
||||||
insufficient,
|
|
||||||
allSufficient: insufficient.length === 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = new DLEV2Service();
|
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contact-table-modal">
|
<div class="contact-table-modal">
|
||||||
<div class="contact-table-header">
|
<div class="contact-table-header">
|
||||||
<el-button type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
|
<el-button v-if="canManageSettings" type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
|
||||||
<el-button type="warning" :disabled="!selectedIds.length" @click="deleteMessagesSelected" style="margin-right: 1em;">Удалить сообщения</el-button>
|
<el-button v-if="canDelete" type="warning" :disabled="!selectedIds.length" @click="deleteMessagesSelected" style="margin-right: 1em;">Удалить сообщения</el-button>
|
||||||
<el-button type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button>
|
<el-button v-if="canDelete" type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button>
|
||||||
<el-button type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button>
|
<el-button v-if="canEdit" type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button>
|
||||||
<button class="close-btn" @click="$emit('close')">×</button>
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<el-form :inline="true" class="filters-form" label-position="top">
|
<el-form :inline="true" class="filters-form" label-position="top">
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<table class="contact-table">
|
<table class="contact-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
|
<th v-if="canEdit || canDelete || canManageSettings"><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Telegram</th>
|
<th>Telegram</th>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
|
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
|
||||||
<td><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
|
<td v-if="canEdit || canDelete || canManageSettings"><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
|
||||||
<td>{{ contact.name || '-' }}</td>
|
<td>{{ contact.name || '-' }}</td>
|
||||||
<td>{{ contact.email || '-' }}</td>
|
<td>{{ contact.email || '-' }}</td>
|
||||||
<td>{{ contact.telegram || '-' }}</td>
|
<td>{{ contact.telegram || '-' }}</td>
|
||||||
@@ -112,6 +112,7 @@ import BroadcastModal from './BroadcastModal.vue';
|
|||||||
import tablesService from '../services/tablesService';
|
import tablesService from '../services/tablesService';
|
||||||
import messagesService from '../services/messagesService';
|
import messagesService from '../services/messagesService';
|
||||||
import { useTagsWebSocket } from '../composables/useTagsWebSocket';
|
import { useTagsWebSocket } from '../composables/useTagsWebSocket';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: { type: Array, default: () => [] },
|
contacts: { type: Array, default: () => [] },
|
||||||
newContacts: { type: Array, default: () => [] },
|
newContacts: { type: Array, default: () => [] },
|
||||||
@@ -123,6 +124,7 @@ const contactsArray = ref([]); // теперь управляем вручную
|
|||||||
const newIds = computed(() => props.newContacts.map(c => c.id));
|
const newIds = computed(() => props.newContacts.map(c => c.id));
|
||||||
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
|
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { canEdit, canDelete, canManageSettings } = usePermissions();
|
||||||
|
|
||||||
// Фильтры
|
// Фильтры
|
||||||
const filterSearch = ref('');
|
const filterSearch = ref('');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="column.type === 'multiselect'">
|
<template v-if="column.type === 'multiselect'">
|
||||||
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
|
<div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
|
||||||
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
|
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
|
||||||
<span v-else class="cell-plus-icon" title="Добавить">
|
<span v-else class="cell-plus-icon" title="Добавить">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.type === 'relation'">
|
<template v-else-if="column.type === 'relation'">
|
||||||
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
|
<div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
|
||||||
<span v-if="selectedRelationName">{{ selectedRelationName }}</span>
|
<span v-if="selectedRelationName">{{ selectedRelationName }}</span>
|
||||||
<span v-else class="cell-plus-icon" title="Добавить">
|
<span v-else class="cell-plus-icon" title="Добавить">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.type === 'multiselect-relation'">
|
<template v-else-if="column.type === 'multiselect-relation'">
|
||||||
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
|
<div v-if="!editing" @click="canEdit && (editing = true)" class="tags-cell-view">
|
||||||
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.join(', ') }}</span>
|
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.join(', ') }}</span>
|
||||||
<span v-else class="cell-plus-icon" title="Добавить">
|
<span v-else class="cell-plus-icon" title="Добавить">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="!editing" class="cell-view-value" @click="editing = true">
|
<div v-if="!editing" class="cell-view-value" @click="canEdit && (editing = true)">
|
||||||
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
|
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
|
||||||
<span v-else-if="localValue">{{ localValue }}</span>
|
<span v-else-if="localValue">{{ localValue }}</span>
|
||||||
<span v-else class="cell-plus-icon" title="Добавить">
|
<span v-else class="cell-plus-icon" title="Добавить">
|
||||||
@@ -128,8 +128,11 @@ import tablesService from '../../services/tablesService';
|
|||||||
import { useTablesWebSocket } from '../../composables/useTablesWebSocket';
|
import { useTablesWebSocket } from '../../composables/useTablesWebSocket';
|
||||||
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
|
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
|
||||||
import cacheService from '../../services/cacheService';
|
import cacheService from '../../services/cacheService';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
const props = defineProps(['rowId', 'column', 'cellValues']);
|
const props = defineProps(['rowId', 'column', 'cellValues']);
|
||||||
const emit = defineEmits(['update']);
|
const emit = defineEmits(['update']);
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
|
|
||||||
const localValue = ref('');
|
const localValue = ref('');
|
||||||
const editing = ref(false);
|
const editing = ref(false);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<h2>{{ tableMeta.name }}</h2>
|
<h2>{{ tableMeta.name }}</h2>
|
||||||
<div class="table-desc">{{ tableMeta.description }}</div>
|
<div class="table-desc">{{ tableMeta.description }}</div>
|
||||||
<div class="table-header-actions" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; margin-bottom: 18px;">
|
<div class="table-header-actions" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; margin-bottom: 18px;">
|
||||||
<el-button type="danger" :disabled="!selectedRows.length" @click="deleteSelectedRows">Удалить выбранные</el-button>
|
<el-button v-if="canEdit" type="danger" :disabled="!selectedRows.length" @click="deleteSelectedRows">Удалить выбранные</el-button>
|
||||||
<span v-if="selectedRows.length">Выбрано: {{ selectedRows.length }}</span>
|
<span v-if="selectedRows.length">Выбрано: {{ selectedRows.length }}</span>
|
||||||
<button v-if="isAdmin" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding">
|
<button v-if="canEdit" class="rebuild-btn" @click="rebuildIndex" :disabled="rebuilding">
|
||||||
{{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }}
|
{{ rebuilding ? 'Пересборка...' : 'Пересобрать индекс' }}
|
||||||
</button>
|
</button>
|
||||||
<el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button>
|
<el-button @click="resetFilters" type="default" icon="el-icon-refresh">Сбросить фильтры</el-button>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span>{{ col.name }}</span>
|
<span>{{ col.name }}</span>
|
||||||
<button class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button>
|
<button v-if="canEdit" class="col-menu" @click.stop="openColMenu(col, $event)">⋮</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
:resizable="false"
|
:resizable="false"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<button class="add-col-btn" @click.stop="openAddMenu($event)" title="Добавить">
|
<button v-if="canEdit" class="add-col-btn" @click.stop="openAddMenu($event)" title="Добавить">
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle cx="11" cy="11" r="10" fill="#f3f4f6" stroke="#b6c6e6"/>
|
<circle cx="11" cy="11" r="10" fill="#f3f4f6" stroke="#b6c6e6"/>
|
||||||
<rect x="10" y="5.5" width="2" height="11" rx="1" fill="#4f8cff"/>
|
<rect x="10" y="5.5" width="2" height="11" rx="1" fill="#4f8cff"/>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</teleport>
|
</teleport>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<button class="row-menu" @click.stop="openRowMenu(row, $event)">⋮</button>
|
<button v-if="canEdit" class="row-menu" @click.stop="openRowMenu(row, $event)">⋮</button>
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
|
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
|
||||||
<button class="menu-item" @click="addRowAfter(row)">Добавить строку</button>
|
<button class="menu-item" @click="addRowAfter(row)">Добавить строку</button>
|
||||||
@@ -170,6 +170,7 @@ import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
|
|||||||
import tablesService from '../../services/tablesService';
|
import tablesService from '../../services/tablesService';
|
||||||
import TableCell from './TableCell.vue';
|
import TableCell from './TableCell.vue';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
// Импортируем компоненты Element Plus
|
// Импортируем компоненты Element Plus
|
||||||
import { ElSelect, ElOption, ElButton } from 'element-plus';
|
import { ElSelect, ElOption, ElButton } from 'element-plus';
|
||||||
@@ -180,6 +181,7 @@ let unsubscribeFromTableUpdate = null;
|
|||||||
let unsubscribeFromTagsUpdate = null;
|
let unsubscribeFromTagsUpdate = null;
|
||||||
|
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
const rebuilding = ref(false);
|
const rebuilding = ref(false);
|
||||||
const rebuildStatus = ref(null);
|
const rebuildStatus = ref(null);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const email = ref(null);
|
|||||||
const processedGuestIds = ref([]);
|
const processedGuestIds = ref([]);
|
||||||
const identities = ref([]);
|
const identities = ref([]);
|
||||||
const tokenBalances = ref([]);
|
const tokenBalances = ref([]);
|
||||||
|
const userAccessLevel = ref({ level: 'user', tokenCount: 0, hasAccess: false });
|
||||||
|
|
||||||
// Функция для обновления списка идентификаторов
|
// Функция для обновления списка идентификаторов
|
||||||
const updateIdentities = async () => {
|
const updateIdentities = async () => {
|
||||||
@@ -95,6 +96,20 @@ const checkTokenBalances = async (address) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkUserAccessLevel = async (address) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/auth/access-level/${address}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
userAccessLevel.value = response.data.data;
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking user access level:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateAuth = async ({
|
const updateAuth = async ({
|
||||||
authenticated,
|
authenticated,
|
||||||
authType: newAuthType,
|
authType: newAuthType,
|
||||||
@@ -140,9 +155,10 @@ const updateAuth = async ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Если аутентификация через кошелек, проверяем баланс токенов
|
// Если аутентификация через кошелек, проверяем баланс токенов и уровень доступа
|
||||||
if (authenticated && newAuthType === 'wallet' && newAddress) {
|
if (authenticated && newAuthType === 'wallet' && newAddress) {
|
||||||
await checkTokenBalances(newAddress);
|
await checkTokenBalances(newAddress);
|
||||||
|
await checkUserAccessLevel(newAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем идентификаторы при любом изменении аутентификации
|
// Обновляем идентификаторы при любом изменении аутентификации
|
||||||
@@ -465,6 +481,7 @@ const authApi = {
|
|||||||
identities,
|
identities,
|
||||||
processedGuestIds,
|
processedGuestIds,
|
||||||
tokenBalances,
|
tokenBalances,
|
||||||
|
userAccessLevel,
|
||||||
updateAuth,
|
updateAuth,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -475,6 +492,7 @@ const authApi = {
|
|||||||
linkIdentity,
|
linkIdentity,
|
||||||
deleteIdentity,
|
deleteIdentity,
|
||||||
checkTokenBalances,
|
checkTokenBalances,
|
||||||
|
checkUserAccessLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// === PROVIDE/INJECT HELPERS ===
|
// === PROVIDE/INJECT HELPERS ===
|
||||||
|
|||||||
105
frontend/src/composables/usePermissions.js
Normal file
105
frontend/src/composables/usePermissions.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This software is proprietary and confidential.
|
||||||
|
* Unauthorized copying, modification, or distribution is prohibited.
|
||||||
|
*
|
||||||
|
* For licensing inquiries: info@hb3-accelerator.com
|
||||||
|
* Website: https://hb3-accelerator.com
|
||||||
|
* GitHub: https://github.com/HB3-ACCELERATOR
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useAuthContext } from './useAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для работы с правами доступа
|
||||||
|
* @returns {Object} - Объект с функциями для проверки прав доступа
|
||||||
|
*/
|
||||||
|
export function usePermissions() {
|
||||||
|
const { userAccessLevel, isAdmin } = useAuthContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, может ли пользователь только читать данные
|
||||||
|
*/
|
||||||
|
const canRead = computed(() => {
|
||||||
|
return userAccessLevel.value && userAccessLevel.value.hasAccess;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, может ли пользователь редактировать данные
|
||||||
|
*/
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, может ли пользователь удалять данные
|
||||||
|
*/
|
||||||
|
const canDelete = computed(() => {
|
||||||
|
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, может ли пользователь управлять настройками системы
|
||||||
|
*/
|
||||||
|
const canManageSettings = computed(() => {
|
||||||
|
return userAccessLevel.value && userAccessLevel.value.level === 'editor';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает текущий уровень доступа
|
||||||
|
*/
|
||||||
|
const currentLevel = computed(() => {
|
||||||
|
return userAccessLevel.value ? userAccessLevel.value.level : 'user';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает количество токенов пользователя
|
||||||
|
*/
|
||||||
|
const tokenCount = computed(() => {
|
||||||
|
return userAccessLevel.value ? userAccessLevel.value.tokenCount : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает описание текущего уровня доступа
|
||||||
|
*/
|
||||||
|
const getLevelDescription = (level) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'readonly':
|
||||||
|
return 'Только чтение';
|
||||||
|
case 'editor':
|
||||||
|
return 'Редактор';
|
||||||
|
case 'user':
|
||||||
|
default:
|
||||||
|
return 'Пользователь';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает CSS класс для уровня доступа
|
||||||
|
*/
|
||||||
|
const getLevelClass = (level) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'readonly':
|
||||||
|
return 'access-readonly';
|
||||||
|
case 'editor':
|
||||||
|
return 'access-editor';
|
||||||
|
case 'user':
|
||||||
|
default:
|
||||||
|
return 'access-user';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
canRead,
|
||||||
|
canEdit,
|
||||||
|
canDelete,
|
||||||
|
canManageSettings,
|
||||||
|
currentLevel,
|
||||||
|
tokenCount,
|
||||||
|
getLevelDescription,
|
||||||
|
getLevelClass
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -212,6 +212,11 @@ const routes = [
|
|||||||
name: 'management-dle-management',
|
name: 'management-dle-management',
|
||||||
component: () => import('../views/smartcontracts/DleManagementView.vue')
|
component: () => import('../views/smartcontracts/DleManagementView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/management/dle-blocks',
|
||||||
|
name: 'management-dle-blocks',
|
||||||
|
component: () => import('../views/smartcontracts/DleBlocksManagementView.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/management/proposals',
|
path: '/management/proposals',
|
||||||
name: 'management-proposals',
|
name: 'management-proposals',
|
||||||
@@ -277,6 +282,11 @@ const routes = [
|
|||||||
name: 'module-deploy-custom',
|
name: 'module-deploy-custom',
|
||||||
component: () => import('../views/smartcontracts/modules/ModuleDeployFormView.vue')
|
component: () => import('../views/smartcontracts/modules/ModuleDeployFormView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/management/modules/deploy/inheritance',
|
||||||
|
name: 'module-deploy-inheritance',
|
||||||
|
component: () => import('../views/smartcontracts/modules/InheritanceModuleDeploy.vue')
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// path: '/management/multisig',
|
// path: '/management/multisig',
|
||||||
// name: 'management-multisig',
|
// name: 'management-multisig',
|
||||||
|
|||||||
@@ -555,32 +555,47 @@ export async function deactivateDLE(dleAddress, userAddress) {
|
|||||||
throw new Error('Подключенный кошелек не совпадает с адресом пользователя');
|
throw new Error('Подключенный кошелек не совпадает с адресом пользователя');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сначала проверяем возможность деактивации через API
|
||||||
|
console.log('Проверяем возможность деактивации DLE через API...');
|
||||||
|
const checkResponse = await api.post('/blockchain/deactivate-dle', {
|
||||||
|
dleAddress: dleAddress,
|
||||||
|
userAddress: userAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkResponse.data.success) {
|
||||||
|
throw new Error(checkResponse.data.error || 'Не удалось проверить возможность деактивации');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Проверка деактивации прошла успешно, выполняем деактивацию...');
|
||||||
|
|
||||||
// ABI для деактивации DLE
|
// ABI для деактивации DLE
|
||||||
const dleAbi = [
|
const dleAbi = [
|
||||||
"function deactivate() external",
|
"function deactivate() external",
|
||||||
"function balanceOf(address) external view returns (uint256)",
|
"function balanceOf(address) external view returns (uint256)",
|
||||||
"function totalSupply() external view returns (uint256)",
|
"function totalSupply() external view returns (uint256)",
|
||||||
"function createDeactivationProposal(string memory _description, uint256 _duration, uint256 _chainId) external returns (uint256)",
|
"function isActive() external view returns (bool)"
|
||||||
"function voteDeactivation(uint256 _proposalId, bool _support) external",
|
|
||||||
"function checkDeactivationProposalResult(uint256 _proposalId) public view returns (bool passed, bool quorumReached)",
|
|
||||||
"function executeDeactivationProposal(uint256 _proposalId) external"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const dle = new ethers.Contract(dleAddress, dleAbi, signer);
|
const dle = new ethers.Contract(dleAddress, dleAbi, signer);
|
||||||
|
|
||||||
// Проверяем, что пользователь имеет токены
|
// Дополнительные проверки перед деактивацией
|
||||||
const balance = await dle.balanceOf(userAddress);
|
const balance = await dle.balanceOf(userAddress);
|
||||||
if (balance <= 0) {
|
if (balance <= 0) {
|
||||||
throw new Error('Для деактивации DLE необходимо иметь токены');
|
throw new Error('Для деактивации DLE необходимо иметь токены');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что DLE не пустой (есть токены)
|
|
||||||
const totalSupply = await dle.totalSupply();
|
const totalSupply = await dle.totalSupply();
|
||||||
if (totalSupply <= 0) {
|
if (totalSupply <= 0) {
|
||||||
throw new Error('DLE не имеет токенов');
|
throw new Error('DLE не имеет токенов');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выполняем деактивацию (функция проверит наличие валидного предложения с кворумом)
|
const isActive = await dle.isActive();
|
||||||
|
if (!isActive) {
|
||||||
|
throw new Error('DLE уже деактивирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем деактивацию
|
||||||
|
console.log('Выполняем деактивацию DLE...');
|
||||||
const tx = await dle.deactivate();
|
const tx = await dle.deactivate();
|
||||||
const receipt = await tx.wait();
|
const receipt = await tx.wait();
|
||||||
|
|
||||||
@@ -595,7 +610,25 @@ export async function deactivateDLE(dleAddress, userAddress) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка деактивации DLE:', error);
|
console.error('Ошибка деактивации DLE:', error);
|
||||||
throw error;
|
|
||||||
|
// Улучшенная обработка ошибок
|
||||||
|
let errorMessage = 'Ошибка при деактивации DLE';
|
||||||
|
|
||||||
|
if (error.message.includes('execution reverted')) {
|
||||||
|
errorMessage = '❌ Деактивация невозможна: не выполнены условия смарт-контракта. Возможно, требуется голосование участников или DLE уже деактивирован.';
|
||||||
|
} else if (error.message.includes('владелец')) {
|
||||||
|
errorMessage = '❌ Только владелец DLE может его деактивировать';
|
||||||
|
} else if (error.message.includes('кошелек')) {
|
||||||
|
errorMessage = '❌ Необходимо подключить кошелек';
|
||||||
|
} else if (error.message.includes('деактивирован')) {
|
||||||
|
errorMessage = '❌ DLE уже деактивирован';
|
||||||
|
} else if (error.message.includes('токены')) {
|
||||||
|
errorMessage = '❌ Для деактивации DLE необходимо иметь токены';
|
||||||
|
} else {
|
||||||
|
errorMessage = `❌ Ошибка: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<span>Контакты</span>
|
<span>Контакты</span>
|
||||||
<span v-if="newContacts.length" class="badge">+{{ newContacts.length }}</span>
|
<span v-if="newContacts.length" class="badge">+{{ newContacts.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ContactTable v-if="isAdmin" :contacts="contacts" :new-contacts="newContacts" :new-messages="newMessages" @markNewAsRead="markContactsAsRead"
|
<ContactTable v-if="canRead" :contacts="contacts" :new-contacts="newContacts" :new-messages="newMessages" @markNewAsRead="markContactsAsRead"
|
||||||
:markMessagesAsReadForUser="markMessagesAsReadForUser" :markContactAsRead="markContactAsRead" @close="goBack" />
|
:markMessagesAsReadForUser="markMessagesAsReadForUser" :markContactAsRead="markContactAsRead" @close="goBack" />
|
||||||
|
|
||||||
<!-- Таблица-заглушка для обычных пользователей -->
|
<!-- Таблица-заглушка для обычных пользователей -->
|
||||||
@@ -92,6 +92,7 @@ import BaseLayout from '../components/BaseLayout.vue';
|
|||||||
import ContactTable from '../components/ContactTable.vue';
|
import ContactTable from '../components/ContactTable.vue';
|
||||||
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
|
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contacts, newContacts, newMessages,
|
contacts, newContacts, newMessages,
|
||||||
@@ -99,6 +100,7 @@ const {
|
|||||||
} = useContactsAndMessagesWebSocket();
|
} = useContactsAndMessagesWebSocket();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canRead } = usePermissions();
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
:is-loading-tokens="isLoadingTokens"
|
:is-loading-tokens="isLoadingTokens"
|
||||||
@auth-action-completed="$emit('auth-action-completed')"
|
@auth-action-completed="$emit('auth-action-completed')"
|
||||||
>
|
>
|
||||||
<template v-if="auth.isAdmin.value">
|
<template v-if="auth.userAccessLevel.value && auth.userAccessLevel.value.hasAccess">
|
||||||
<ChatInterface
|
<ChatInterface
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:is-loading="isLoading || isConnectingWallet"
|
:is-loading="isLoading || isConnectingWallet"
|
||||||
|
|||||||
@@ -54,8 +54,7 @@
|
|||||||
v-for="dle in deployedDles"
|
v-for="dle in deployedDles"
|
||||||
:key="dle.dleAddress"
|
:key="dle.dleAddress"
|
||||||
class="dle-card"
|
class="dle-card"
|
||||||
:class="{ 'selected': selectedDle && selectedDle.dleAddress === dle.dleAddress }"
|
@click="openDleManagement(dle.dleAddress)"
|
||||||
@click="selectDle(dle)"
|
|
||||||
>
|
>
|
||||||
<div class="dle-header">
|
<div class="dle-header">
|
||||||
<div class="dle-title-section">
|
<div class="dle-title-section">
|
||||||
@@ -148,6 +147,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<button class="details-btn btn-sm" @click.stop="refreshVerification(dle.dleAddress)">Обновить статус</button>
|
<button class="details-btn btn-sm" @click.stop="refreshVerification(dle.dleAddress)">Обновить статус</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -157,64 +157,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Блоки управления выбранным DLE -->
|
|
||||||
<div v-if="selectedDle" class="management-blocks">
|
|
||||||
|
|
||||||
<!-- Первый ряд -->
|
|
||||||
<div class="blocks-row">
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Предложения</h3>
|
|
||||||
<p>Создание, подписание, выполнение</p>
|
|
||||||
<button class="details-btn" @click="openProposalsWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Токены DLE</h3>
|
|
||||||
<p>Балансы, трансферы, распределение</p>
|
|
||||||
<button class="details-btn" @click="openTokensWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Кворум</h3>
|
|
||||||
<p>Настройки голосования</p>
|
|
||||||
<button class="details-btn" @click="openQuorumWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Второй ряд -->
|
|
||||||
<div class="blocks-row">
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Модули DLE</h3>
|
|
||||||
<p>Установка, настройка, управление</p>
|
|
||||||
<button class="details-btn" @click="openModulesWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Аналитика</h3>
|
|
||||||
<p>Графики, статистика, отчеты</p>
|
|
||||||
<button class="details-btn" @click="openAnalyticsWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Третий ряд -->
|
|
||||||
<div class="blocks-row">
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>История</h3>
|
|
||||||
<p>Лог операций, события, транзакции</p>
|
|
||||||
<button class="details-btn" @click="openHistoryWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="management-block">
|
|
||||||
<h3>Настройки</h3>
|
|
||||||
<p>Параметры DLE, конфигурация</p>
|
|
||||||
<button class="details-btn" @click="openSettingsWithDle">Подробнее</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +187,6 @@ const router = useRouter();
|
|||||||
// Состояние для DLE
|
// Состояние для DLE
|
||||||
const deployedDles = ref([]);
|
const deployedDles = ref([]);
|
||||||
const isLoadingDles = ref(false);
|
const isLoadingDles = ref(false);
|
||||||
const selectedDle = ref(null);
|
|
||||||
const verificationStatuses = ref({}); // { [address]: { address, chains: { [chainId]: { guid, status } } } }
|
const verificationStatuses = ref({}); // { [address]: { address, chains: { [chainId]: { guid, status } } } }
|
||||||
let verifyPollTimer = null;
|
let verifyPollTimer = null;
|
||||||
|
|
||||||
@@ -431,14 +372,10 @@ function openDleOnEtherscan(address) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDleManagement(dleAddress) {
|
function openDleManagement(dleAddress) {
|
||||||
// Переход к детальному управлению DLE (если нужно)
|
// Переход к блокам управления DLE
|
||||||
router.push(`/management/dle-management?address=${dleAddress}`);
|
router.push(`/management/dle-blocks?address=${dleAddress}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDle(dle) {
|
|
||||||
selectedDle.value = dle;
|
|
||||||
console.log('Выбран DLE:', dle);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshVerification(address) {
|
async function refreshVerification(address) {
|
||||||
try {
|
try {
|
||||||
@@ -474,56 +411,6 @@ async function pollVerifications() {
|
|||||||
// router.push('/management/multisig');
|
// router.push('/management/multisig');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Функции с передачей адреса DLE
|
|
||||||
function openProposalsWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/proposals?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openTokensWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/tokens?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openQuorumWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/quorum?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModulesWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/modules?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function openAnalyticsWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/analytics?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHistoryWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/history?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettingsWithDle() {
|
|
||||||
if (selectedDle.value) {
|
|
||||||
router.push(`/management/settings?address=${selectedDle.value.dleAddress}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// function openMultisigWithDle() {
|
|
||||||
// if (selectedDle.value) {
|
|
||||||
// router.push(`/management/multisig?address=${selectedDle.value.dleAddress}`);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -586,37 +473,6 @@ onBeforeUnmount(() => {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Блоки управления */
|
|
||||||
.management-blocks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocks-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.management-block {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
||||||
padding: 2rem;
|
|
||||||
min-width: 260px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.management-block:hover {
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Секция деплоированных DLE */
|
/* Секция деплоированных DLE */
|
||||||
.deployed-dles-section {
|
.deployed-dles-section {
|
||||||
@@ -905,6 +761,7 @@ onBeforeUnmount(() => {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Стили для отображения логотипа */
|
/* Стили для отображения логотипа */
|
||||||
.dle-title-section {
|
.dle-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -998,17 +855,6 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* Адаптивность */
|
/* Адаптивность */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.blocks-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.management-block {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.management-block h3 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partner-info {
|
.partner-info {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
<p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p>
|
<p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p>
|
||||||
<p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p>
|
<p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p>
|
||||||
<div class="confirm-actions">
|
<div class="confirm-actions">
|
||||||
<button v-if="isAdmin" class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button>
|
<button v-if="canDelete" class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button>
|
||||||
<button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button>
|
<button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isAdmin" class="empty-table-placeholder">Нет прав для удаления контакта</div>
|
<div v-if="!canDelete" class="empty-table-placeholder">Нет прав для удаления контакта</div>
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +36,7 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import contactsService from '../../services/contactsService.js';
|
import contactsService from '../../services/contactsService.js';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,6 +45,7 @@ const isLoading = ref(true);
|
|||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canDelete } = usePermissions();
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
if (!date) return '-';
|
if (!date) return '-';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<div v-if="!isAdmin" class="empty-table-placeholder">Нет доступа</div>
|
<div v-if="!canRead" class="empty-table-placeholder">Нет доступа</div>
|
||||||
<div v-else class="contact-details-page">
|
<div v-else class="contact-details-page">
|
||||||
<div v-if="isLoading">Загрузка...</div>
|
<div v-if="isLoading">Загрузка...</div>
|
||||||
<div v-else-if="!contact">Контакт не найден</div>
|
<div v-else-if="!contact">Контакт не найден</div>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="contact-info-block">
|
<div class="contact-info-block">
|
||||||
<div>
|
<div>
|
||||||
<strong>Имя:</strong>
|
<strong>Имя:</strong>
|
||||||
<template v-if="isAdmin">
|
<template v-if="canEdit">
|
||||||
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
|
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
|
||||||
<span v-if="isSavingName" class="saving">Сохранение...</span>
|
<span v-if="isSavingName" class="saving">Сохранение...</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,9 +41,10 @@
|
|||||||
<div class="selected-langs">
|
<div class="selected-langs">
|
||||||
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
|
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
|
||||||
{{ getLanguageLabel(lang) }}
|
{{ getLanguageLabel(lang) }}
|
||||||
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
|
<span v-if="canEdit" class="remove-tag" @click="removeLanguage(lang)">×</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
v-if="canEdit"
|
||||||
v-model="langInput"
|
v-model="langInput"
|
||||||
@focus="showLangDropdown = true"
|
@focus="showLangDropdown = true"
|
||||||
@input="showLangDropdown = true"
|
@input="showLangDropdown = true"
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
placeholder="Добавить язык..."
|
placeholder="Добавить язык..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="showLangDropdown" class="lang-dropdown">
|
<ul v-if="showLangDropdown && canEdit" class="lang-dropdown">
|
||||||
<li
|
<li
|
||||||
v-for="lang in filteredLanguages"
|
v-for="lang in filteredLanguages"
|
||||||
:key="lang.value"
|
:key="lang.value"
|
||||||
@@ -71,15 +72,15 @@
|
|||||||
<strong>Теги пользователя:</strong>
|
<strong>Теги пользователя:</strong>
|
||||||
<span v-for="tag in userTags" :key="tag.id" class="user-tag">
|
<span v-for="tag in userTags" :key="tag.id" class="user-tag">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
<span class="remove-tag" @click="removeUserTag(tag.id)">×</span>
|
<span v-if="canEdit" class="remove-tag" @click="removeUserTag(tag.id)">×</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
|
<button v-if="canEdit" class="add-tag-btn" @click="openTagModal">Добавить тег</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="block-user-section">
|
<div class="block-user-section">
|
||||||
<strong>Статус блокировки:</strong>
|
<strong>Статус блокировки:</strong>
|
||||||
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
|
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
|
||||||
<span v-else class="unblocked-status">Не заблокирован</span>
|
<span v-else class="unblocked-status">Не заблокирован</span>
|
||||||
<template v-if="isAdmin">
|
<template v-if="canEdit">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!contact.is_blocked"
|
v-if="!contact.is_blocked"
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -108,14 +109,14 @@
|
|||||||
:isLoading="isLoadingMessages"
|
:isLoading="isLoadingMessages"
|
||||||
:attachments="chatAttachments"
|
:attachments="chatAttachments"
|
||||||
:newMessage="chatNewMessage"
|
:newMessage="chatNewMessage"
|
||||||
:isAdmin="isAdmin"
|
:isAdmin="canEdit"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
@update:newMessage="val => chatNewMessage = val"
|
@update:newMessage="val => chatNewMessage = val"
|
||||||
@update:attachments="val => chatAttachments = val"
|
@update:attachments="val => chatAttachments = val"
|
||||||
@ai-reply="handleAiReply"
|
@ai-reply="handleAiReply"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="showTagModal" title="Добавить тег пользователю">
|
<el-dialog v-if="canEdit" v-model="showTagModal" title="Добавить тег пользователю">
|
||||||
<div v-if="allTags.length">
|
<div v-if="allTags.length">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedTags"
|
v-model="selectedTags"
|
||||||
@@ -158,6 +159,7 @@ import ChatInterface from '../../components/ChatInterface.vue';
|
|||||||
import contactsService from '../../services/contactsService.js';
|
import contactsService from '../../services/contactsService.js';
|
||||||
import messagesService from '../../services/messagesService.js';
|
import messagesService from '../../services/messagesService.js';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessageBox } from 'element-plus';
|
||||||
import tablesService from '../../services/tablesService';
|
import tablesService from '../../services/tablesService';
|
||||||
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
|
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
|
||||||
@@ -182,6 +184,7 @@ const messages = ref([]);
|
|||||||
const chatAttachments = ref([]);
|
const chatAttachments = ref([]);
|
||||||
const chatNewMessage = ref('');
|
const chatNewMessage = ref('');
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canRead, canEdit, canDelete } = usePermissions();
|
||||||
const isAiLoading = ref(false);
|
const isAiLoading = ref(false);
|
||||||
const conversationId = ref(null);
|
const conversationId = ref(null);
|
||||||
|
|
||||||
@@ -250,6 +253,7 @@ async function loadAllTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTagModal() {
|
function openTagModal() {
|
||||||
|
if (!canEdit.value) return;
|
||||||
showTagModal.value = true;
|
showTagModal.value = true;
|
||||||
loadAllTags();
|
loadAllTags();
|
||||||
}
|
}
|
||||||
@@ -289,6 +293,7 @@ function getLanguageLabel(val) {
|
|||||||
return found ? found.label : val;
|
return found ? found.label : val;
|
||||||
}
|
}
|
||||||
function addLanguage(lang) {
|
function addLanguage(lang) {
|
||||||
|
if (!canEdit.value) return;
|
||||||
if (!selectedLanguages.value.includes(lang)) {
|
if (!selectedLanguages.value.includes(lang)) {
|
||||||
selectedLanguages.value.push(lang);
|
selectedLanguages.value.push(lang);
|
||||||
saveLanguages();
|
saveLanguages();
|
||||||
@@ -297,14 +302,17 @@ function addLanguage(lang) {
|
|||||||
showLangDropdown.value = false;
|
showLangDropdown.value = false;
|
||||||
}
|
}
|
||||||
function addLanguageFromInput() {
|
function addLanguageFromInput() {
|
||||||
|
if (!canEdit.value) return;
|
||||||
const found = filteredLanguages.value[0];
|
const found = filteredLanguages.value[0];
|
||||||
if (found) addLanguage(found.value);
|
if (found) addLanguage(found.value);
|
||||||
}
|
}
|
||||||
function removeLanguage(lang) {
|
function removeLanguage(lang) {
|
||||||
|
if (!canEdit.value) return;
|
||||||
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
|
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
|
||||||
saveLanguages();
|
saveLanguages();
|
||||||
}
|
}
|
||||||
function saveLanguages() {
|
function saveLanguages() {
|
||||||
|
if (!canEdit.value) return;
|
||||||
isSavingLangs.value = true;
|
isSavingLangs.value = true;
|
||||||
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
|
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
|
||||||
.then(() => reloadContact())
|
.then(() => reloadContact())
|
||||||
@@ -529,6 +537,7 @@ async function unblockUser() {
|
|||||||
|
|
||||||
// --- Теги ---
|
// --- Теги ---
|
||||||
async function createTag() {
|
async function createTag() {
|
||||||
|
if (!canEdit.value) return;
|
||||||
if (!newTagName.value) return;
|
if (!newTagName.value) return;
|
||||||
const tableId = await ensureTagsTable();
|
const tableId = await ensureTagsTable();
|
||||||
const table = await tablesService.getTable(tableId);
|
const table = await tablesService.getTable(tableId);
|
||||||
@@ -588,6 +597,7 @@ async function loadUserTags() {
|
|||||||
|
|
||||||
// После добавления/удаления тегов всегда обновляем userTags
|
// После добавления/удаления тегов всегда обновляем userTags
|
||||||
async function addTagsToUser() {
|
async function addTagsToUser() {
|
||||||
|
if (!canEdit.value) return;
|
||||||
if (!contact.value || !contact.value.id) return;
|
if (!contact.value || !contact.value.id) return;
|
||||||
if (!selectedTags.value || selectedTags.value.length === 0) return;
|
if (!selectedTags.value || selectedTags.value.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -601,6 +611,7 @@ async function addTagsToUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeUserTag(tagId) {
|
async function removeUserTag(tagId) {
|
||||||
|
if (!canEdit.value) return;
|
||||||
if (!contact.value || !contact.value.id) return;
|
if (!contact.value || !contact.value.id) return;
|
||||||
try {
|
try {
|
||||||
await contactsService.removeTagFromContact(contact.value.id, tagId);
|
await contactsService.removeTagFromContact(contact.value.id, tagId);
|
||||||
|
|||||||
@@ -101,6 +101,80 @@
|
|||||||
{{ em.from_email }}
|
{{ em.from_email }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- Настройки RAG поиска -->
|
||||||
|
<div class="rag-search-settings">
|
||||||
|
<h3>Настройки RAG поиска</h3>
|
||||||
|
|
||||||
|
<!-- Метод поиска -->
|
||||||
|
<label>Метод поиска</label>
|
||||||
|
<select v-model="ragSettings.searchMethod">
|
||||||
|
<option value="semantic">Только семантический поиск</option>
|
||||||
|
<option value="keyword">Только поиск по ключевым словам</option>
|
||||||
|
<option value="hybrid">Гибридный поиск</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Количество результатов -->
|
||||||
|
<label>Максимальное количество результатов поиска</label>
|
||||||
|
<input type="number" v-model="ragSettings.maxResults" min="1" max="20" />
|
||||||
|
|
||||||
|
<!-- Порог релевантности -->
|
||||||
|
<label>Порог релевантности ({{ ragSettings.relevanceThreshold }})</label>
|
||||||
|
<input type="range" v-model="ragSettings.relevanceThreshold"
|
||||||
|
min="0.01" max="1.0" step="0.01" />
|
||||||
|
|
||||||
|
<!-- Настройки извлечения ключевых слов -->
|
||||||
|
<div class="keyword-settings">
|
||||||
|
<h4>Извлечение ключевых слов</h4>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="ragSettings.keywordExtraction.enabled" />
|
||||||
|
Включить извлечение ключевых слов
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Минимальная длина слова</label>
|
||||||
|
<input type="number" v-model="ragSettings.keywordExtraction.minWordLength"
|
||||||
|
min="2" max="10" />
|
||||||
|
|
||||||
|
<label>Максимальное количество ключевых слов</label>
|
||||||
|
<input type="number" v-model="ragSettings.keywordExtraction.maxKeywords"
|
||||||
|
min="5" max="20" />
|
||||||
|
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="ragSettings.keywordExtraction.removeStopWords" />
|
||||||
|
Удалять стоп-слова
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Веса для гибридного поиска -->
|
||||||
|
<div v-if="ragSettings.searchMethod === 'hybrid'" class="search-weights">
|
||||||
|
<h4>Веса поиска</h4>
|
||||||
|
<label>Семантический поиск: {{ ragSettings.searchWeights.semantic }}%</label>
|
||||||
|
<input type="range" v-model="ragSettings.searchWeights.semantic"
|
||||||
|
min="0" max="100" />
|
||||||
|
|
||||||
|
<label>Поиск по ключевым словам: {{ ragSettings.searchWeights.keyword }}%</label>
|
||||||
|
<input type="range" v-model="ragSettings.searchWeights.keyword"
|
||||||
|
min="0" max="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дополнительные настройки -->
|
||||||
|
<div class="advanced-settings">
|
||||||
|
<h4>Дополнительные настройки</h4>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="ragSettings.advanced.enableFuzzySearch" />
|
||||||
|
Нечеткий поиск
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="ragSettings.advanced.enableStemming" />
|
||||||
|
Стемминг слов
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="ragSettings.advanced.enableSynonyms" />
|
||||||
|
Поиск синонимов
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Сохранить</button>
|
<button type="submit">Сохранить</button>
|
||||||
<button type="button" @click="goBack">Отмена</button>
|
<button type="button" @click="goBack">Отмена</button>
|
||||||
@@ -143,6 +217,29 @@ const placeholders = ref([]);
|
|||||||
const editingPlaceholder = ref(null);
|
const editingPlaceholder = ref(null);
|
||||||
const editingPlaceholderValue = ref('');
|
const editingPlaceholderValue = ref('');
|
||||||
|
|
||||||
|
// Настройки RAG поиска
|
||||||
|
const ragSettings = ref({
|
||||||
|
searchMethod: 'hybrid',
|
||||||
|
maxResults: 5,
|
||||||
|
relevanceThreshold: 0.1,
|
||||||
|
keywordExtraction: {
|
||||||
|
enabled: true,
|
||||||
|
minWordLength: 3,
|
||||||
|
maxKeywords: 10,
|
||||||
|
removeStopWords: true,
|
||||||
|
language: 'ru'
|
||||||
|
},
|
||||||
|
searchWeights: {
|
||||||
|
semantic: 70,
|
||||||
|
keyword: 30
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
enableFuzzySearch: true,
|
||||||
|
enableStemming: true,
|
||||||
|
enableSynonyms: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadUserTables() {
|
async function loadUserTables() {
|
||||||
const { data } = await axios.get('/tables');
|
const { data } = await axios.get('/tables');
|
||||||
userTables.value = Array.isArray(data) ? data : [];
|
userTables.value = Array.isArray(data) ? data : [];
|
||||||
@@ -165,7 +262,14 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.value = settingsData;
|
settings.value = settingsData;
|
||||||
|
|
||||||
|
// Загружаем настройки RAG, если они есть
|
||||||
|
if (data.settings.ragSettings) {
|
||||||
|
ragSettings.value = { ...ragSettings.value, ...data.settings.ragSettings };
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[AiAssistantSettings] Loaded settings:', settings.value);
|
console.log('[AiAssistantSettings] Loaded settings:', settings.value);
|
||||||
|
console.log('[AiAssistantSettings] Loaded RAG settings:', ragSettings.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadTelegramBots() {
|
async function loadTelegramBots() {
|
||||||
@@ -225,7 +329,11 @@ async function saveSettings() {
|
|||||||
settingsToSave.selected_rag_tables = [settingsToSave.selected_rag_tables];
|
settingsToSave.selected_rag_tables = [settingsToSave.selected_rag_tables];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем настройки RAG
|
||||||
|
settingsToSave.ragSettings = ragSettings.value;
|
||||||
|
|
||||||
console.log('[AiAssistantSettings] Saving settings:', settingsToSave);
|
console.log('[AiAssistantSettings] Saving settings:', settingsToSave);
|
||||||
|
console.log('[AiAssistantSettings] Saving RAG settings:', ragSettings.value);
|
||||||
await axios.put('/settings/ai-assistant', settingsToSave);
|
await axios.put('/settings/ai-assistant', settingsToSave);
|
||||||
goBack();
|
goBack();
|
||||||
}
|
}
|
||||||
@@ -411,4 +519,63 @@ button[type="button"] {
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
margin: 0.7em 0;
|
margin: 0.7em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для настроек RAG поиска */
|
||||||
|
.rag-search-settings {
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rag-search-settings h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-settings, .search-weights, .advanced-settings {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-settings h4, .search-weights h4, .advanced-settings h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #555;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-weights input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rag-search-settings input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rag-search-settings input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import AIProviderSettings from './AIProviderSettings.vue';
|
import AIProviderSettings from './AIProviderSettings.vue';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import NoAccessModal from '@/components/NoAccessModal.vue';
|
import NoAccessModal from '@/components/NoAccessModal.vue';
|
||||||
|
|
||||||
const showProvider = ref(null);
|
const showProvider = ref(null);
|
||||||
@@ -80,6 +81,7 @@ const showAiAssistantSettings = ref(false);
|
|||||||
const showNoAccessModal = ref(false);
|
const showNoAccessModal = ref(false);
|
||||||
|
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canManageSettings } = usePermissions();
|
||||||
|
|
||||||
const providerLabels = {
|
const providerLabels = {
|
||||||
openai: {
|
openai: {
|
||||||
@@ -117,7 +119,7 @@ const providerLabels = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function goTo(path) {
|
function goTo(path) {
|
||||||
if (!isAdmin.value) {
|
if (!canManageSettings.value) {
|
||||||
showNoAccessModal.value = true;
|
showNoAccessModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="auth-tokens-settings">
|
<div class="auth-tokens-settings">
|
||||||
<h4>Токены аутентификации</h4>
|
<h4>Токены аутентификации</h4>
|
||||||
|
|
||||||
|
<!-- Отображение текущего уровня доступа -->
|
||||||
|
<div v-if="userAccessLevel && userAccessLevel.hasAccess" class="access-level-info">
|
||||||
|
<div class="access-level-badge" :class="getLevelClass(userAccessLevel.level)">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
<span>{{ getLevelDescription(userAccessLevel.level) }}</span>
|
||||||
|
<span class="token-count">({{ userAccessLevel.tokenCount }} токен{{ userAccessLevel.tokenCount === 1 ? '' : userAccessLevel.tokenCount < 5 ? 'а' : 'ов' }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="access-level-description">
|
||||||
|
{{ getAccessLevelDescription(userAccessLevel.level) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="authTokens.length > 0" class="tokens-list">
|
<div v-if="authTokens.length > 0" class="tokens-list">
|
||||||
<div v-for="(token, index) in authTokens" :key="token.address + token.network" class="token-entry">
|
<div v-for="(token, index) in authTokens" :key="token.address + token.network" class="token-entry">
|
||||||
<span><strong>Название:</strong> {{ token.name }}</span>
|
<span><strong>Название:</strong> {{ token.name }}</span>
|
||||||
<span><strong>Адрес:</strong> {{ token.address }}</span>
|
<span><strong>Адрес:</strong> {{ token.address }}</span>
|
||||||
<span><strong>Сеть:</strong> {{ getNetworkLabel(token.network) }}</span>
|
<span><strong>Сеть:</strong> {{ getNetworkLabel(token.network) }}</span>
|
||||||
<span><strong>Мин. баланс:</strong> {{ token.minBalance }}</span>
|
<span><strong>Мин. баланс:</strong> {{ token.minBalance }}</span>
|
||||||
|
<span><strong>Read-Only:</strong> {{ token.readonlyThreshold || 1 }} токен{{ token.readonlyThreshold === 1 ? '' : 'а' }}</span>
|
||||||
|
<span><strong>Editor:</strong> {{ token.editorThreshold || 2 }} токен{{ token.editorThreshold === 1 ? '' : token.editorThreshold < 5 ? 'а' : 'ов' }}</span>
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
:class="isAdmin ? 'btn-danger' : 'btn-secondary'"
|
:class="canEdit ? 'btn-danger' : 'btn-secondary'"
|
||||||
@click="isAdmin ? removeToken(index) : null"
|
@click="canEdit ? removeToken(index) : null"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
@@ -39,7 +53,7 @@
|
|||||||
v-model="newToken.name"
|
v-model="newToken.name"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="test2"
|
placeholder="test2"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -49,12 +63,12 @@
|
|||||||
v-model="newToken.address"
|
v-model="newToken.address"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="0x..."
|
placeholder="0x..."
|
||||||
:disabled="!isAdmin"
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Сеть:</label>
|
<label>Сеть:</label>
|
||||||
<select v-model="newToken.network" class="form-control" :disabled="!isAdmin">
|
<select v-model="newToken.network" class="form-control" :disabled="!canEdit">
|
||||||
<option value="">-- Выберите сеть --</option>
|
<option value="">-- Выберите сеть --</option>
|
||||||
<optgroup v-for="(group, groupIndex) in networkGroups" :key="groupIndex" :label="group.label">
|
<optgroup v-for="(group, groupIndex) in networkGroups" :key="groupIndex" :label="group.label">
|
||||||
<option v-for="option in group.options" :key="option.value" :value="option.value">
|
<option v-for="option in group.options" :key="option.value" :value="option.value">
|
||||||
@@ -70,14 +84,43 @@
|
|||||||
v-model.number="newToken.minBalance"
|
v-model.number="newToken.minBalance"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Настройки прав доступа -->
|
||||||
|
<div class="access-settings">
|
||||||
|
<h6>Настройки прав доступа</h6>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Минимум токенов для Read-Only доступа:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model="newToken.readonlyThreshold"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="1"
|
||||||
|
min="1"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
>
|
||||||
|
<small class="form-text">Количество токенов для получения прав только на чтение</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Минимум токенов для Editor доступа:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model="newToken.editorThreshold"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="2"
|
||||||
|
min="2"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
>
|
||||||
|
<small class="form-text">Количество токенов для получения прав на редактирование и удаление</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
:class="isAdmin ? 'btn-secondary' : 'btn-secondary'"
|
:class="canEdit ? 'btn-primary' : 'btn-secondary'"
|
||||||
@click="isAdmin ? addToken() : null"
|
@click="canEdit ? addToken() : null"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
Добавить токен
|
Добавить токен
|
||||||
</button>
|
</button>
|
||||||
@@ -86,36 +129,58 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue';
|
import { reactive, computed } from 'vue';
|
||||||
import useBlockchainNetworks from '@/composables/useBlockchainNetworks';
|
import useBlockchainNetworks from '@/composables/useBlockchainNetworks';
|
||||||
import api from '@/api/axios';
|
import api from '@/api/axios';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import eventBus from '@/utils/eventBus';
|
import eventBus from '@/utils/eventBus';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
authTokens: { type: Array, required: true }
|
authTokens: { type: Array, required: true }
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update']);
|
const emit = defineEmits(['update']);
|
||||||
const newToken = reactive({ name: '', address: '', network: '', minBalance: 0 });
|
const newToken = reactive({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
network: '',
|
||||||
|
minBalance: 0,
|
||||||
|
readonlyThreshold: 1,
|
||||||
|
editorThreshold: 2
|
||||||
|
});
|
||||||
|
|
||||||
const { networkGroups, networks } = useBlockchainNetworks();
|
const { networkGroups, networks } = useBlockchainNetworks();
|
||||||
const { isAdmin, checkTokenBalances, address, checkAuth } = useAuthContext();
|
const { isAdmin, checkTokenBalances, address, checkAuth, userAccessLevel, checkUserAccessLevel } = useAuthContext();
|
||||||
|
const { canEdit, getLevelClass, getLevelDescription } = usePermissions();
|
||||||
|
|
||||||
async function addToken() {
|
async function addToken() {
|
||||||
if (!newToken.name || !newToken.address || !newToken.network) {
|
if (!newToken.name || !newToken.address || !newToken.network) {
|
||||||
alert('Все поля обязательны');
|
alert('Все поля обязательны');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenData = {
|
||||||
|
name: newToken.name,
|
||||||
|
address: newToken.address,
|
||||||
|
network: newToken.network,
|
||||||
|
minBalance: Number(newToken.minBalance) || 0,
|
||||||
|
readonlyThreshold: newToken.readonlyThreshold !== null && newToken.readonlyThreshold !== undefined && newToken.readonlyThreshold !== '' ? Number(newToken.readonlyThreshold) : 1,
|
||||||
|
editorThreshold: newToken.editorThreshold !== null && newToken.editorThreshold !== undefined && newToken.editorThreshold !== '' ? Number(newToken.editorThreshold) : 2
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[AuthTokensSettings] Отправляем данные токена:', tokenData);
|
||||||
|
console.log('[AuthTokensSettings] newToken объект:', newToken);
|
||||||
|
console.log('[AuthTokensSettings] newToken.readonlyThreshold:', newToken.readonlyThreshold, 'тип:', typeof newToken.readonlyThreshold);
|
||||||
|
console.log('[AuthTokensSettings] newToken.editorThreshold:', newToken.editorThreshold, 'тип:', typeof newToken.editorThreshold);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/settings/auth-token', {
|
await api.post('/settings/auth-token', tokenData);
|
||||||
...newToken,
|
|
||||||
minBalance: Number(newToken.minBalance) || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// После добавления токена перепроверяем баланс пользователя и обновляем состояние аутентификации
|
// После добавления токена перепроверяем баланс пользователя и обновляем состояние аутентификации
|
||||||
try {
|
try {
|
||||||
if (address.value) {
|
if (address.value) {
|
||||||
await checkTokenBalances(address.value);
|
await checkTokenBalances(address.value);
|
||||||
console.log('[AuthTokensSettings] Баланс токенов перепроверен после добавления');
|
await checkUserAccessLevel(address.value);
|
||||||
|
console.log('[AuthTokensSettings] Баланс токенов и уровень доступа перепроверены после добавления');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем состояние аутентификации чтобы отразить изменения роли
|
// Обновляем состояние аутентификации чтобы отразить изменения роли
|
||||||
@@ -138,6 +203,8 @@ async function addToken() {
|
|||||||
newToken.address = '';
|
newToken.address = '';
|
||||||
newToken.network = '';
|
newToken.network = '';
|
||||||
newToken.minBalance = 0;
|
newToken.minBalance = 0;
|
||||||
|
newToken.readonlyThreshold = 1;
|
||||||
|
newToken.editorThreshold = 2;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка при добавлении токена: ' + (e.response?.data?.error || e.message));
|
alert('Ошибка при добавлении токена: ' + (e.response?.data?.error || e.message));
|
||||||
}
|
}
|
||||||
@@ -159,7 +226,8 @@ async function removeToken(index) {
|
|||||||
try {
|
try {
|
||||||
if (address.value) {
|
if (address.value) {
|
||||||
await checkTokenBalances(address.value);
|
await checkTokenBalances(address.value);
|
||||||
console.log('[AuthTokensSettings] Баланс токенов перепроверен после удаления');
|
await checkUserAccessLevel(address.value);
|
||||||
|
console.log('[AuthTokensSettings] Баланс токенов и уровень доступа перепроверены после удаления');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем состояние аутентификации чтобы отразить изменения роли
|
// Обновляем состояние аутентификации чтобы отразить изменения роли
|
||||||
@@ -188,13 +256,118 @@ function getNetworkLabel(networkId) {
|
|||||||
const found = networks.value.find(n => n.value === networkId);
|
const found = networks.value.find(n => n.value === networkId);
|
||||||
return found ? found.label : networkId;
|
return found ? found.label : networkId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getAccessLevelDescription(level) {
|
||||||
|
switch (level) {
|
||||||
|
case 'readonly':
|
||||||
|
return 'Можете просматривать данные, но не можете редактировать или удалять';
|
||||||
|
case 'editor':
|
||||||
|
return 'Можете просматривать, редактировать и удалять данные';
|
||||||
|
case 'user':
|
||||||
|
default:
|
||||||
|
return 'Базовые права пользователя';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tokens-list { margin-bottom: 1rem; }
|
.tokens-list { margin-bottom: 1rem; }
|
||||||
.token-entry { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; }
|
.token-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-entry span {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.add-token-form { margin-top: 1rem; }
|
.add-token-form { margin-top: 1rem; }
|
||||||
|
|
||||||
|
/* Стили для секции настроек прав доступа */
|
||||||
|
.access-settings {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-settings h6 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для отображения уровня доступа */
|
||||||
|
.access-level-info {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-level-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-level-badge i {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-readonly {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-editor {
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-user {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-count {
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-level-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Стили для неактивных кнопок */
|
/* Стили для неактивных кнопок */
|
||||||
.btn[disabled], .btn:disabled {
|
.btn[disabled], .btn:disabled {
|
||||||
background: #e0e0e0 !important;
|
background: #e0e0e0 !important;
|
||||||
|
|||||||
@@ -858,7 +858,7 @@
|
|||||||
@click="deploySmartContracts"
|
@click="deploySmartContracts"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-lg deploy-btn"
|
class="btn btn-primary btn-lg deploy-btn"
|
||||||
:disabled="!isFormValid || !adminTokenCheck.isAdmin || adminTokenCheck.isLoading || showDeployProgress"
|
:disabled="!isFormValid || !canEdit || adminTokenCheck.isLoading || showDeployProgress"
|
||||||
:title="`isFormValid: ${isFormValid}, isAdmin: ${adminTokenCheck.isAdmin}, isLoading: ${adminTokenCheck.isLoading}, showDeployProgress: ${showDeployProgress}`"
|
:title="`isFormValid: ${isFormValid}, isAdmin: ${adminTokenCheck.isAdmin}, isLoading: ${adminTokenCheck.isLoading}, showDeployProgress: ${showDeployProgress}`"
|
||||||
>
|
>
|
||||||
<i class="fas fa-cogs"></i>
|
<i class="fas fa-cogs"></i>
|
||||||
@@ -941,6 +941,7 @@
|
|||||||
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import api from '@/api/axios';
|
import api from '@/api/axios';
|
||||||
import DeploymentWizard from '@/components/deployment/DeploymentWizard.vue';
|
import DeploymentWizard from '@/components/deployment/DeploymentWizard.vue';
|
||||||
|
|
||||||
@@ -959,6 +960,7 @@ function normalizePrivateKey(raw) {
|
|||||||
|
|
||||||
// Получаем контекст авторизации для адреса кошелька
|
// Получаем контекст авторизации для адреса кошелька
|
||||||
const { address, isAdmin } = useAuthContext();
|
const { address, isAdmin } = useAuthContext();
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
|
|
||||||
// Состояние для проверки админских токенов
|
// Состояние для проверки админских токенов
|
||||||
const adminTokenCheck = ref({
|
const adminTokenCheck = ref({
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@click="isAdmin ? goToAkashDetails() : null"
|
@click="canManageSettings ? goToAkashDetails() : null"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canManageSettings"
|
||||||
>
|
>
|
||||||
Подробнее
|
Подробнее
|
||||||
</button>
|
</button>
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@click="isAdmin ? goToFluxDetails() : null"
|
@click="canManageSettings ? goToFluxDetails() : null"
|
||||||
:disabled="!isAdmin"
|
:disabled="!canManageSettings"
|
||||||
>
|
>
|
||||||
Подробнее
|
Подробнее
|
||||||
</button>
|
</button>
|
||||||
@@ -91,10 +91,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import NoAccessModal from '@/components/NoAccessModal.vue';
|
import NoAccessModal from '@/components/NoAccessModal.vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canManageSettings } = usePermissions();
|
||||||
const goBack = () => router.push('/settings');
|
const goBack = () => router.push('/settings');
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import RpcProvidersSettings from './RpcProvidersSettings.vue';
|
|||||||
import AuthTokensSettings from './AuthTokensSettings.vue';
|
import AuthTokensSettings from './AuthTokensSettings.vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import NoAccessModal from '@/components/NoAccessModal.vue';
|
import NoAccessModal from '@/components/NoAccessModal.vue';
|
||||||
import wsClient from '@/utils/websocket';
|
import wsClient from '@/utils/websocket';
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ const showNoAccessModal = ref(false);
|
|||||||
|
|
||||||
// Получаем контекст авторизации
|
// Получаем контекст авторизации
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canManageSettings } = usePermissions();
|
||||||
|
|
||||||
// Настройки безопасности
|
// Настройки безопасности
|
||||||
const securitySettings = reactive({
|
const securitySettings = reactive({
|
||||||
@@ -168,7 +170,9 @@ const loadSettings = async () => {
|
|||||||
name: token.name,
|
name: token.name,
|
||||||
address: token.address,
|
address: token.address,
|
||||||
network: token.network,
|
network: token.network,
|
||||||
minBalance: token.min_balance
|
minBalance: token.min_balance,
|
||||||
|
readonlyThreshold: token.readonly_threshold || 1,
|
||||||
|
editorThreshold: token.editor_threshold || 2
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,11 +335,11 @@ provide('networks', networks);
|
|||||||
|
|
||||||
// Функция для обработки клика по кнопке "Подробнее" для RPC провайдеров
|
// Функция для обработки клика по кнопке "Подробнее" для RPC провайдеров
|
||||||
const handleRpcDetailsClick = () => {
|
const handleRpcDetailsClick = () => {
|
||||||
if (isAdmin.value) {
|
if (canManageSettings.value) {
|
||||||
// Если администратор - показываем детали RPC
|
// Если есть права на управление настройками - показываем детали RPC
|
||||||
showRpcSettings.value = !showRpcSettings.value;
|
showRpcSettings.value = !showRpcSettings.value;
|
||||||
} else {
|
} else {
|
||||||
// Если обычный пользователь - показываем модальное окно с ограничением доступа
|
// Если нет прав - показываем модальное окно с ограничением доступа
|
||||||
showNoAccessModal.value = true;
|
showNoAccessModal.value = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<p v-else-if="isLoadingDle">Загрузка...</p>
|
<p v-else-if="isLoadingDle">Загрузка...</p>
|
||||||
<p v-else>Подробная аналитика и статистика DLE</p>
|
<p v-else>Подробная аналитика и статистика DLE</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Основная информация -->
|
<!-- Основная информация -->
|
||||||
@@ -252,6 +252,15 @@ const route = useRoute();
|
|||||||
// Получаем адрес DLE из URL параметров
|
// Получаем адрес DLE из URL параметров
|
||||||
const dleAddress = ref(route.query.address || '');
|
const dleAddress = ref(route.query.address || '');
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const selectedDle = ref(null);
|
const selectedDle = ref(null);
|
||||||
const isLoadingDle = ref(false);
|
const isLoadingDle = ref(false);
|
||||||
|
|||||||
298
frontend/src/views/smartcontracts/DleBlocksManagementView.vue
Normal file
298
frontend/src/views/smartcontracts/DleBlocksManagementView.vue
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is proprietary and confidential.
|
||||||
|
Unauthorized copying, modification, or distribution is prohibited.
|
||||||
|
|
||||||
|
For licensing inquiries: info@hb3-accelerator.com
|
||||||
|
Website: https://hb3-accelerator.com
|
||||||
|
GitHub: https://github.com/HB3-ACCELERATOR
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseLayout
|
||||||
|
:is-authenticated="isAuthenticated"
|
||||||
|
:identities="identities"
|
||||||
|
:token-balances="tokenBalances"
|
||||||
|
:is-loading-tokens="isLoadingTokens"
|
||||||
|
@auth-action-completed="$emit('auth-action-completed')"
|
||||||
|
>
|
||||||
|
<div class="dle-blocks-management">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="management-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Управление DLE</h1>
|
||||||
|
<p v-if="dleAddress" class="dle-address">
|
||||||
|
<strong>DLE:</strong> {{ dleAddress }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" @click="router.push('/management')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Блоки управления -->
|
||||||
|
<div class="management-blocks">
|
||||||
|
<!-- Первый ряд -->
|
||||||
|
<div class="blocks-row">
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Предложения</h3>
|
||||||
|
<p>Создание, подписание, выполнение</p>
|
||||||
|
<button class="details-btn" @click="openProposals">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Токены DLE</h3>
|
||||||
|
<p>Балансы, трансферы, распределение</p>
|
||||||
|
<button class="details-btn" @click="openTokens">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Кворум</h3>
|
||||||
|
<p>Настройки голосования</p>
|
||||||
|
<button class="details-btn" @click="openQuorum">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Второй ряд -->
|
||||||
|
<div class="blocks-row">
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Модули DLE</h3>
|
||||||
|
<p>Установка, настройка, управление</p>
|
||||||
|
<button class="details-btn" @click="openModules">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Аналитика</h3>
|
||||||
|
<p>Графики, статистика, отчеты</p>
|
||||||
|
<button class="details-btn" @click="openAnalytics">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Третий ряд -->
|
||||||
|
<div class="blocks-row">
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>История</h3>
|
||||||
|
<p>Лог операций, события, транзакции</p>
|
||||||
|
<button class="details-btn" @click="openHistory">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="management-block">
|
||||||
|
<h3>Настройки</h3>
|
||||||
|
<p>Параметры DLE, конфигурация</p>
|
||||||
|
<button class="details-btn" @click="openSettings">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import BaseLayout from '../../components/BaseLayout.vue';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
isAuthenticated: Boolean,
|
||||||
|
identities: Array,
|
||||||
|
tokenBalances: Object,
|
||||||
|
isLoadingTokens: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['auth-action-completed']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Получаем адрес DLE из query параметров
|
||||||
|
const dleAddress = computed(() => route.query.address || null);
|
||||||
|
|
||||||
|
// Функции для открытия страниц управления
|
||||||
|
const openProposals = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/proposals?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/proposals');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTokens = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/tokens?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openQuorum = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/quorum?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/quorum');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModules = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/modules?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/modules');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAnalytics = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/analytics?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/analytics');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistory = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/history?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/history');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/settings?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management/settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Если нет адреса DLE, перенаправляем на главную страницу management
|
||||||
|
if (!dleAddress.value) {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dle-blocks-management {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: var(--color-white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dle-address {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-blocks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocks-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-block {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-block:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-block h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-block p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-btn {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-btn:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.blocks-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<p v-else-if="isLoadingDle">Загрузка...</p>
|
<p v-else-if="isLoadingDle">Загрузка...</p>
|
||||||
<p v-else>DLE не выбран</p>
|
<p v-else>DLE не выбран</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Фильтры и управление -->
|
<!-- Фильтры и управление -->
|
||||||
@@ -900,6 +900,15 @@ const dleAddress = computed(() => {
|
|||||||
return address;
|
return address;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние DLE
|
// Состояние DLE
|
||||||
const selectedDle = ref(null);
|
const selectedDle = ref(null);
|
||||||
const isLoadingDle = ref(false);
|
const isLoadingDle = ref(false);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<p v-else-if="isLoadingDle">Загрузка...</p>
|
<p v-else-if="isLoadingDle">Загрузка...</p>
|
||||||
<p v-else>Лог операций, события и транзакции DLE</p>
|
<p v-else>Лог операций, события и транзакции DLE</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Фильтры -->
|
<!-- Фильтры -->
|
||||||
@@ -281,6 +281,15 @@ const route = useRoute();
|
|||||||
// Получаем адрес DLE из URL параметров
|
// Получаем адрес DLE из URL параметров
|
||||||
const dleAddress = ref(route.query.address || '');
|
const dleAddress = ref(route.query.address || '');
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const selectedDle = ref(null);
|
const selectedDle = ref(null);
|
||||||
const isLoadingDle = ref(false);
|
const isLoadingDle = ref(false);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<p v-else-if="isLoadingDle">Загрузка...</p>
|
<p v-else-if="isLoadingDle">Загрузка...</p>
|
||||||
<p v-else>DLE не выбран</p>
|
<p v-else>DLE не выбран</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Информация о модулях -->
|
<!-- Информация о модулях -->
|
||||||
@@ -665,6 +665,20 @@ const emit = defineEmits(['auth-action-completed']);
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Получаем адрес DLE из URL
|
||||||
|
const dleAddress = computed(() => {
|
||||||
|
return route.query.address;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const selectedDle = ref(null);
|
const selectedDle = ref(null);
|
||||||
const isLoadingDle = ref(false);
|
const isLoadingDle = ref(false);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<h1>Кворум</h1>
|
<h1>Кворум</h1>
|
||||||
<p>Настройки голосования и кворума</p>
|
<p>Настройки голосования и кворума</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Текущие настройки -->
|
<!-- Текущие настройки -->
|
||||||
@@ -181,8 +181,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, defineEmits } from 'vue';
|
import { ref, computed, defineProps, defineEmits } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import BaseLayout from '../../components/BaseLayout.vue';
|
import BaseLayout from '../../components/BaseLayout.vue';
|
||||||
import { getGovernanceParams } from '../../services/dleV2Service.js';
|
import { getGovernanceParams } from '../../services/dleV2Service.js';
|
||||||
import { getQuorumAt, getVotingPowerAt } from '../../services/proposalsService.js';
|
import { getQuorumAt, getVotingPowerAt } from '../../services/proposalsService.js';
|
||||||
@@ -199,6 +199,21 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['auth-action-completed']);
|
const emit = defineEmits(['auth-action-completed']);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Получаем адрес DLE из URL
|
||||||
|
const dleAddress = computed(() => {
|
||||||
|
return route.query.address;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<p v-else-if="address">Загрузка...</p>
|
<p v-else-if="address">Загрузка...</p>
|
||||||
<p v-else>DLE не выбран</p>
|
<p v-else>DLE не выбран</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<!-- Основной контент -->
|
||||||
@@ -39,6 +39,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="danger-content">
|
<div class="danger-content">
|
||||||
<p>Полное удаление DLE и всех связанных данных. Это действие необратимо.</p>
|
<p>Полное удаление DLE и всех связанных данных. Это действие необратимо.</p>
|
||||||
|
<div class="warning-info">
|
||||||
|
<h4>⚠️ Важно:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Для деактивации DLE необходимо иметь токены</li>
|
||||||
|
<li>Может потребоваться голосование участников</li>
|
||||||
|
<li>DLE должен быть активен</li>
|
||||||
|
<li>Только владелец токенов может инициировать деактивацию</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<button @click="deleteDLE" class="btn-danger" :disabled="isLoading">
|
<button @click="deleteDLE" class="btn-danger" :disabled="isLoading">
|
||||||
{{ isLoading ? 'Загрузка...' : 'Удалить DLE' }}
|
{{ isLoading ? 'Загрузка...' : 'Удалить DLE' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -50,7 +59,7 @@
|
|||||||
<div v-if="!address" class="no-dle-card">
|
<div v-if="!address" class="no-dle-card">
|
||||||
<h3>DLE не выбран</h3>
|
<h3>DLE не выбран</h3>
|
||||||
<p>Для управления настройками необходимо выбрать DLE</p>
|
<p>Для управления настройками необходимо выбрать DLE</p>
|
||||||
<button @click="router.push('/management')" class="btn-primary">
|
<button @click="goBackToBlocks" class="btn-primary">
|
||||||
Вернуться к списку DLE
|
Вернуться к списку DLE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +98,15 @@ const isLoading = ref(false);
|
|||||||
// Получаем адрес DLE из URL параметров
|
// Получаем адрес DLE из URL параметров
|
||||||
const address = route.query.address || props.dleAddress;
|
const address = route.query.address || props.dleAddress;
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (address) {
|
||||||
|
router.push(`/management/dle-blocks?address=${address}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Получаем адрес пользователя из контекста аутентификации
|
// Получаем адрес пользователя из контекста аутентификации
|
||||||
const { address: userAddress } = useAuthContext();
|
const { address: userAddress } = useAuthContext();
|
||||||
|
|
||||||
@@ -167,20 +185,26 @@ const deleteDLE = async () => {
|
|||||||
|
|
||||||
alert(`✅ DLE ${dleInfo.value?.name || address} успешно деактивирован!\n\nТранзакция: ${result.txHash}`);
|
alert(`✅ DLE ${dleInfo.value?.name || address} успешно деактивирован!\n\nТранзакция: ${result.txHash}`);
|
||||||
|
|
||||||
// Перенаправляем на главную страницу управления
|
// Перенаправляем на страницу блоков управления
|
||||||
router.push('/management');
|
goBackToBlocks();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при деактивации DLE:', error);
|
console.error('Ошибка при деактивации DLE:', error);
|
||||||
|
|
||||||
let errorMessage = 'Ошибка при деактивации DLE';
|
let errorMessage = 'Ошибка при деактивации DLE';
|
||||||
|
|
||||||
if (error.message.includes('владелец')) {
|
if (error.message.includes('execution reverted')) {
|
||||||
|
errorMessage = '❌ Деактивация невозможна: не выполнены условия смарт-контракта. Возможно, требуется голосование участников или DLE уже деактивирован.';
|
||||||
|
} else if (error.message.includes('владелец')) {
|
||||||
errorMessage = '❌ Только владелец DLE может его деактивировать';
|
errorMessage = '❌ Только владелец DLE может его деактивировать';
|
||||||
} else if (error.message.includes('кошелек')) {
|
} else if (error.message.includes('кошелек')) {
|
||||||
errorMessage = '❌ Необходимо подключить кошелек';
|
errorMessage = '❌ Необходимо подключить кошелек';
|
||||||
} else if (error.message.includes('деактивирован')) {
|
} else if (error.message.includes('деактивирован')) {
|
||||||
errorMessage = '❌ DLE уже деактивирован';
|
errorMessage = '❌ DLE уже деактивирован';
|
||||||
|
} else if (error.message.includes('токены')) {
|
||||||
|
errorMessage = '❌ Для деактивации DLE необходимо иметь токены';
|
||||||
|
} else if (error.message.includes('условия смарт-контракта')) {
|
||||||
|
errorMessage = error.message; // Используем сообщение из dle-contract.js
|
||||||
} else {
|
} else {
|
||||||
errorMessage = `❌ Ошибка: ${error.message}`;
|
errorMessage = `❌ Ошибка: ${error.message}`;
|
||||||
}
|
}
|
||||||
@@ -345,4 +369,31 @@ onMounted(() => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для блока предупреждения */
|
||||||
|
.warning-info {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-info h4 {
|
||||||
|
color: #856404;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-info ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-info li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<span>DLE не выбран</span>
|
<span>DLE не выбран</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="router.push('/management')">×</button>
|
<button class="close-btn" @click="goBackToBlocks">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Информация о токенах -->
|
<!-- Информация о токенах -->
|
||||||
@@ -182,6 +182,15 @@ const dleAddress = computed(() => {
|
|||||||
return address;
|
return address;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Функция возврата к блокам управления
|
||||||
|
const goBackToBlocks = () => {
|
||||||
|
if (dleAddress.value) {
|
||||||
|
router.push(`/management/dle-blocks?address=${dleAddress.value}`);
|
||||||
|
} else {
|
||||||
|
router.push('/management');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Состояние DLE
|
// Состояние DLE
|
||||||
const selectedDle = ref(null);
|
const selectedDle = ref(null);
|
||||||
const isLoadingDle = ref(false);
|
const isLoadingDle = ref(false);
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма деплоя модуля во всех сетях -->
|
<!-- Форма деплоя модуля администратором -->
|
||||||
<div class="deploy-form">
|
<div v-if="canManageSettings" class="deploy-form">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h3>🌐 Деплой DLEReader во всех сетях</h3>
|
<h3>🔧 Деплой DLEReader администратором</h3>
|
||||||
<p>Деплой API модуля для чтения данных во всех 4 сетях одновременно</p>
|
<p>Администратор деплоит модуль, затем создает предложение для добавления в DLE</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-content">
|
<div class="form-content">
|
||||||
@@ -85,22 +85,39 @@
|
|||||||
<h4>⚙️ Настройки DLEReader:</h4>
|
<h4>⚙️ Настройки DLEReader:</h4>
|
||||||
|
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
|
<!-- Поля администратора -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h5>🔐 Настройки администратора:</h5>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="chainId">ID сети:</label>
|
<label for="adminPrivateKey">Приватный ключ администратора:</label>
|
||||||
<select
|
<input
|
||||||
id="chainId"
|
type="password"
|
||||||
v-model="moduleSettings.chainId"
|
id="adminPrivateKey"
|
||||||
|
v-model="moduleSettings.adminPrivateKey"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
placeholder="0x..."
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="11155111">Sepolia (11155111)</option>
|
<small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
|
||||||
<option value="17000">Holesky (17000)</option>
|
|
||||||
<option value="421614">Arbitrum Sepolia (421614)</option>
|
|
||||||
<option value="84532">Base Sepolia (84532)</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">ID сети для деплоя модуля</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="etherscanApiKey">Etherscan API ключ:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="etherscanApiKey"
|
||||||
|
v-model="moduleSettings.etherscanApiKey"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="YourAPIKey..."
|
||||||
|
>
|
||||||
|
<small class="form-help">API ключ для автоматической верификации контрактов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="simple-info">
|
<div class="simple-info">
|
||||||
<h5>📋 Информация о DLEReader:</h5>
|
<h5>📋 Информация о DLEReader:</h5>
|
||||||
<div class="info-text">
|
<div class="info-text">
|
||||||
@@ -122,12 +139,17 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-large deploy-module"
|
class="btn btn-primary btn-large deploy-module"
|
||||||
@click="deployDLEReader"
|
@click="deployDLEReader"
|
||||||
:disabled="isDeploying || !dleAddress"
|
:disabled="isDeploying || !dleAddress || !isFormValid"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
||||||
{{ isDeploying ? 'Деплой модуля...' : 'Деплой DLEReader' }}
|
{{ isDeploying ? 'Деплой модуля...' : 'Деплой DLEReader' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>Заполните приватный ключ и API ключ для деплоя</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="deploymentProgress" class="deployment-progress">
|
<div v-if="deploymentProgress" class="deployment-progress">
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ deploymentProgress.message }}</span>
|
<span>{{ deploymentProgress.message }}</span>
|
||||||
@@ -141,14 +163,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Сообщение для пользователей без прав доступа -->
|
||||||
|
<div v-if="!canManageSettings" class="no-access-message">
|
||||||
|
<div class="message-content">
|
||||||
|
<h3>🔒 Нет прав доступа</h3>
|
||||||
|
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
|
||||||
|
<button class="btn btn-secondary" @click="router.push('/management/modules')">
|
||||||
|
← Вернуться к модулям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import BaseLayout from '../../../components/BaseLayout.vue';
|
import BaseLayout from '../../../components/BaseLayout.vue';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -162,6 +196,7 @@ const emit = defineEmits(['auth-action-completed']);
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { canEdit, canManageSettings } = usePermissions();
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
@@ -171,12 +206,23 @@ const deploymentProgress = ref(null);
|
|||||||
|
|
||||||
// Настройки модуля
|
// Настройки модуля
|
||||||
const moduleSettings = ref({
|
const moduleSettings = ref({
|
||||||
// Единственный параметр - ID сети
|
// Поля администратора
|
||||||
chainId: 11155111
|
adminPrivateKey: '',
|
||||||
|
etherscanApiKey: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка валидности формы
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция деплоя DLEReader
|
// Функция деплоя DLEReader
|
||||||
async function deployDLEReader() {
|
async function deployDLEReader() {
|
||||||
|
if (!canManageSettings.value) {
|
||||||
|
alert('У вас нет прав для деплоя смарт-контрактов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDeploying.value = true;
|
isDeploying.value = true;
|
||||||
deploymentProgress.value = {
|
deploymentProgress.value = {
|
||||||
@@ -186,8 +232,8 @@ async function deployDLEReader() {
|
|||||||
|
|
||||||
console.log('[DLEReaderDeployView] Начинаем деплой DLEReader для DLE:', dleAddress.value);
|
console.log('[DLEReaderDeployView] Начинаем деплой DLEReader для DLE:', dleAddress.value);
|
||||||
|
|
||||||
// Вызываем API для деплоя модуля во всех сетях
|
// Вызываем API для деплоя модуля администратором
|
||||||
const response = await fetch('/api/dle-modules/deploy-reader', {
|
const response = await fetch('/api/dle-modules/deploy-reader-admin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -195,9 +241,11 @@ async function deployDLEReader() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
dleAddress: dleAddress.value,
|
dleAddress: dleAddress.value,
|
||||||
moduleType: 'reader',
|
moduleType: 'reader',
|
||||||
|
adminPrivateKey: moduleSettings.value.adminPrivateKey,
|
||||||
|
etherscanApiKey: moduleSettings.value.etherscanApiKey,
|
||||||
settings: {
|
settings: {
|
||||||
// Единственный параметр - ID сети
|
// Используем настройки по умолчанию
|
||||||
chainId: moduleSettings.value.chainId
|
useDefaultSettings: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -217,12 +265,31 @@ async function deployDLEReader() {
|
|||||||
percentage: 100
|
percentage: 100
|
||||||
};
|
};
|
||||||
|
|
||||||
alert('✅ Деплой DLEReader запущен во всех сетях!');
|
// Показываем детальную информацию о деплое
|
||||||
|
const deployInfo = result.data || {};
|
||||||
|
const deployedAddresses = deployInfo.addresses || [];
|
||||||
|
|
||||||
|
let successMessage = '✅ DLEReader успешно задеплоен!\n\n';
|
||||||
|
successMessage += `📊 Детали деплоя:\n`;
|
||||||
|
successMessage += `• DLE: ${dleAddress.value}\n`;
|
||||||
|
successMessage += `• Тип модуля: DLEReader\n`;
|
||||||
|
successMessage += `• Адрес модуля: ${deployInfo.moduleAddress || 'Не указан'}\n`;
|
||||||
|
|
||||||
|
if (deployedAddresses.length > 0) {
|
||||||
|
successMessage += `\n🌐 Задеплоенные адреса:\n`;
|
||||||
|
deployedAddresses.forEach((addr, index) => {
|
||||||
|
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
|
||||||
|
|
||||||
|
alert(successMessage);
|
||||||
|
|
||||||
// Перенаправляем обратно к модулям
|
// Перенаправляем обратно к модулям
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(`/management/modules?address=${dleAddress.value}`);
|
router.push(`/management/modules?address=${dleAddress.value}`);
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Неизвестная ошибка');
|
throw new Error(result.error || 'Неизвестная ошибка');
|
||||||
@@ -440,6 +507,22 @@ onMounted(() => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Секция администратора */
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Простая информация */
|
/* Простая информация */
|
||||||
.simple-info {
|
.simple-info {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -582,4 +665,43 @@ onMounted(() => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Сообщение об отсутствии прав доступа */
|
||||||
|
.no-access-message {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 30px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content h3 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма деплоя модуля во всех сетях -->
|
<!-- Форма деплоя модуля администратором -->
|
||||||
<div class="deploy-form">
|
<div v-if="canManageSettings" class="deploy-form">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h3>🌐 Деплой TimelockModule во всех сетях</h3>
|
<h3>🔧 Деплой TimelockModule администратором</h3>
|
||||||
<p>Деплой модуля временных задержек во всех 4 сетях одновременно</p>
|
<p>Администратор деплоит модуль, затем создает предложение для добавления в DLE</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-content">
|
<div class="form-content">
|
||||||
@@ -85,193 +85,49 @@
|
|||||||
<h4>⚙️ Настройки TimelockModule:</h4>
|
<h4>⚙️ Настройки TimelockModule:</h4>
|
||||||
|
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
|
<!-- Поля администратора -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h5>🔐 Настройки администратора:</h5>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="chainId">ID сети:</label>
|
<label for="adminPrivateKey">Приватный ключ администратора:</label>
|
||||||
<select
|
<input
|
||||||
id="chainId"
|
type="password"
|
||||||
v-model="moduleSettings.chainId"
|
id="adminPrivateKey"
|
||||||
|
v-model="moduleSettings.adminPrivateKey"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
placeholder="0x..."
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="11155111">Sepolia (11155111)</option>
|
<small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
|
||||||
<option value="17000">Holesky (17000)</option>
|
|
||||||
<option value="421614">Arbitrum Sepolia (421614)</option>
|
|
||||||
<option value="84532">Base Sepolia (84532)</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">ID сети для деплоя модуля</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="defaultDelay">Стандартная задержка (дни):</label>
|
<label for="etherscanApiKey">Etherscan API ключ:</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
id="defaultDelay"
|
id="etherscanApiKey"
|
||||||
v-model="moduleSettings.defaultDelay"
|
v-model="moduleSettings.etherscanApiKey"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
min="1"
|
placeholder="YourAPIKey..."
|
||||||
max="30"
|
|
||||||
placeholder="2"
|
|
||||||
>
|
>
|
||||||
<small class="form-help">Стандартная задержка для операций (1-30 дней)</small>
|
<small class="form-help">API ключ для автоматической верификации контрактов</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="simple-info">
|
||||||
<div class="form-group">
|
<h5>📋 Информация о TimelockModule:</h5>
|
||||||
<label for="emergencyDelay">Экстренная задержка (минуты):</label>
|
<div class="info-text">
|
||||||
<input
|
<p><strong>TimelockModule</strong> будет задеплоен с настройками по умолчанию:</p>
|
||||||
type="number"
|
<ul>
|
||||||
id="emergencyDelay"
|
<li>✅ Стандартная задержка: 2 дня</li>
|
||||||
v-model="moduleSettings.emergencyDelay"
|
<li>✅ Экстренная задержка: 30 минут</li>
|
||||||
class="form-control"
|
<li>✅ Автоматическое исполнение операций</li>
|
||||||
min="5"
|
<li>✅ Готовые настройки безопасности</li>
|
||||||
max="1440"
|
</ul>
|
||||||
placeholder="30"
|
<p><em>После деплоя настройки можно будет изменить через governance.</em></p>
|
||||||
>
|
|
||||||
<small class="form-help">Экстренная задержка для критических операций (5-1440 минут)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="maxDelay">Максимальная задержка (дни):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="maxDelay"
|
|
||||||
v-model="moduleSettings.maxDelay"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="365"
|
|
||||||
placeholder="30"
|
|
||||||
>
|
|
||||||
<small class="form-help">Максимальная задержка для операций (1-365 дней)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="minDelay">Минимальная задержка (часы):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="minDelay"
|
|
||||||
v-model="moduleSettings.minDelay"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="720"
|
|
||||||
placeholder="24"
|
|
||||||
>
|
|
||||||
<small class="form-help">Минимальная задержка для операций (1-720 часов)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="maxOperations">Максимум операций в очереди:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="maxOperations"
|
|
||||||
v-model="moduleSettings.maxOperations"
|
|
||||||
class="form-control"
|
|
||||||
min="10"
|
|
||||||
max="1000"
|
|
||||||
placeholder="100"
|
|
||||||
>
|
|
||||||
<small class="form-help">Максимальное количество операций в очереди (10-1000)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Дополнительные настройки таймлока -->
|
|
||||||
<div class="advanced-settings">
|
|
||||||
<h5>🔧 Дополнительные настройки таймлока:</h5>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="criticalOperations">Критические операции (JSON формат):</label>
|
|
||||||
<textarea
|
|
||||||
id="criticalOperations"
|
|
||||||
v-model="moduleSettings.criticalOperations"
|
|
||||||
class="form-control"
|
|
||||||
rows="3"
|
|
||||||
placeholder='["0x12345678", "0x87654321"]'
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Селекторы функций, которые считаются критическими (JSON массив)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="emergencyOperations">Экстренные операции (JSON формат):</label>
|
|
||||||
<textarea
|
|
||||||
id="emergencyOperations"
|
|
||||||
v-model="moduleSettings.emergencyOperations"
|
|
||||||
class="form-control"
|
|
||||||
rows="3"
|
|
||||||
placeholder='["0xabcdef12", "0x21fedcba"]'
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Селекторы функций для экстренных операций (JSON массив)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="operationDelays">Задержки для операций (JSON формат):</label>
|
|
||||||
<textarea
|
|
||||||
id="operationDelays"
|
|
||||||
v-model="moduleSettings.operationDelays"
|
|
||||||
class="form-control"
|
|
||||||
rows="4"
|
|
||||||
placeholder='{"0x12345678": 86400, "0x87654321": 172800}'
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Кастомные задержки для конкретных операций (селектор => секунды)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="autoExecuteEnabled">Автоисполнение включено:</label>
|
|
||||||
<select
|
|
||||||
id="autoExecuteEnabled"
|
|
||||||
v-model="moduleSettings.autoExecuteEnabled"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<option value="true">Включено</option>
|
|
||||||
<option value="false">Отключено</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">Автоматическое исполнение операций после истечения задержки</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cancellationWindow">Окно отмены (часы):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="cancellationWindow"
|
|
||||||
v-model="moduleSettings.cancellationWindow"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="168"
|
|
||||||
placeholder="24"
|
|
||||||
>
|
|
||||||
<small class="form-help">Время, в течение которого можно отменить операцию (1-168 часов)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="executionWindow">Окно исполнения (часы):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="executionWindow"
|
|
||||||
v-model="moduleSettings.executionWindow"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="168"
|
|
||||||
placeholder="48"
|
|
||||||
>
|
|
||||||
<small class="form-help">Время, в течение которого можно исполнить операцию (1-168 часов)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timelockDescription">Описание таймлока:</label>
|
|
||||||
<textarea
|
|
||||||
id="timelockDescription"
|
|
||||||
v-model="moduleSettings.timelockDescription"
|
|
||||||
class="form-control"
|
|
||||||
rows="2"
|
|
||||||
placeholder="Описание таймлока DLE для безопасности операций..."
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Описание таймлока для документации</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,12 +138,17 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-large deploy-module"
|
class="btn btn-primary btn-large deploy-module"
|
||||||
@click="deployTimelockModule"
|
@click="deployTimelockModule"
|
||||||
:disabled="isDeploying || !dleAddress"
|
:disabled="isDeploying || !dleAddress || !isFormValid"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
||||||
{{ isDeploying ? 'Деплой модуля...' : 'Деплой TimelockModule' }}
|
{{ isDeploying ? 'Деплой модуля...' : 'Деплой TimelockModule' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>Заполните приватный ключ и API ключ для деплоя</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="deploymentProgress" class="deployment-progress">
|
<div v-if="deploymentProgress" class="deployment-progress">
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ deploymentProgress.message }}</span>
|
<span>{{ deploymentProgress.message }}</span>
|
||||||
@@ -301,14 +162,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Сообщение для пользователей без прав доступа -->
|
||||||
|
<div v-if="!canManageSettings" class="no-access-message">
|
||||||
|
<div class="message-content">
|
||||||
|
<h3>🔒 Нет прав доступа</h3>
|
||||||
|
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
|
||||||
|
<button class="btn btn-secondary" @click="router.push('/management/modules')">
|
||||||
|
← Вернуться к модулям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, ref, onMounted } from 'vue';
|
import { defineProps, defineEmits, ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import BaseLayout from '../../../components/BaseLayout.vue';
|
import BaseLayout from '../../../components/BaseLayout.vue';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
// Определяем props
|
// Определяем props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -323,6 +196,7 @@ const emit = defineEmits(['auth-action-completed']);
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { canEdit, canManageSettings } = usePermissions();
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
@@ -332,26 +206,23 @@ const deploymentProgress = ref(null);
|
|||||||
|
|
||||||
// Настройки модуля
|
// Настройки модуля
|
||||||
const moduleSettings = ref({
|
const moduleSettings = ref({
|
||||||
// Основные параметры
|
// Поля администратора
|
||||||
chainId: 11155111,
|
adminPrivateKey: '',
|
||||||
defaultDelay: 2, // days
|
etherscanApiKey: ''
|
||||||
emergencyDelay: 30, // minutes
|
});
|
||||||
maxDelay: 30, // days
|
|
||||||
minDelay: 24, // hours
|
|
||||||
|
|
||||||
// Дополнительные настройки
|
// Проверка валидности формы
|
||||||
maxOperations: 100,
|
const isFormValid = computed(() => {
|
||||||
criticalOperations: '',
|
return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
|
||||||
emergencyOperations: '',
|
|
||||||
operationDelays: '',
|
|
||||||
autoExecuteEnabled: 'true',
|
|
||||||
cancellationWindow: 24, // hours
|
|
||||||
executionWindow: 48, // hours
|
|
||||||
timelockDescription: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция деплоя TimelockModule
|
// Функция деплоя TimelockModule
|
||||||
async function deployTimelockModule() {
|
async function deployTimelockModule() {
|
||||||
|
if (!canManageSettings.value) {
|
||||||
|
alert('У вас нет прав для деплоя смарт-контрактов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDeploying.value = true;
|
isDeploying.value = true;
|
||||||
deploymentProgress.value = {
|
deploymentProgress.value = {
|
||||||
@@ -361,8 +232,8 @@ async function deployTimelockModule() {
|
|||||||
|
|
||||||
console.log('[TimelockModuleDeployView] Начинаем деплой TimelockModule для DLE:', dleAddress.value);
|
console.log('[TimelockModuleDeployView] Начинаем деплой TimelockModule для DLE:', dleAddress.value);
|
||||||
|
|
||||||
// Вызываем API для деплоя модуля во всех сетях
|
// Вызываем API для деплоя модуля администратором
|
||||||
const response = await fetch('/api/dle-modules/deploy-timelock', {
|
const response = await fetch('/api/dle-modules/deploy-timelock-admin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -370,23 +241,11 @@ async function deployTimelockModule() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
dleAddress: dleAddress.value,
|
dleAddress: dleAddress.value,
|
||||||
moduleType: 'timelock',
|
moduleType: 'timelock',
|
||||||
|
adminPrivateKey: moduleSettings.value.adminPrivateKey,
|
||||||
|
etherscanApiKey: moduleSettings.value.etherscanApiKey,
|
||||||
settings: {
|
settings: {
|
||||||
// Основные параметры
|
// Используем настройки по умолчанию
|
||||||
chainId: moduleSettings.value.chainId,
|
useDefaultSettings: true
|
||||||
defaultDelay: moduleSettings.value.defaultDelay * 24 * 60 * 60, // конвертируем дни в секунды
|
|
||||||
emergencyDelay: moduleSettings.value.emergencyDelay * 60, // конвертируем минуты в секунды
|
|
||||||
maxDelay: moduleSettings.value.maxDelay * 24 * 60 * 60, // конвертируем дни в секунды
|
|
||||||
minDelay: moduleSettings.value.minDelay * 60 * 60, // конвертируем часы в секунды
|
|
||||||
|
|
||||||
// Дополнительные настройки
|
|
||||||
maxOperations: parseInt(moduleSettings.value.maxOperations),
|
|
||||||
criticalOperations: moduleSettings.value.criticalOperations ? JSON.parse(moduleSettings.value.criticalOperations) : [],
|
|
||||||
emergencyOperations: moduleSettings.value.emergencyOperations ? JSON.parse(moduleSettings.value.emergencyOperations) : [],
|
|
||||||
operationDelays: moduleSettings.value.operationDelays ? JSON.parse(moduleSettings.value.operationDelays) : {},
|
|
||||||
autoExecuteEnabled: moduleSettings.value.autoExecuteEnabled === 'true',
|
|
||||||
cancellationWindow: moduleSettings.value.cancellationWindow * 60 * 60, // конвертируем часы в секунды
|
|
||||||
executionWindow: moduleSettings.value.executionWindow * 60 * 60, // конвертируем часы в секунды
|
|
||||||
timelockDescription: moduleSettings.value.timelockDescription
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -406,12 +265,31 @@ async function deployTimelockModule() {
|
|||||||
percentage: 100
|
percentage: 100
|
||||||
};
|
};
|
||||||
|
|
||||||
alert('✅ Деплой TimelockModule запущен во всех сетях!');
|
// Показываем детальную информацию о деплое
|
||||||
|
const deployInfo = result.data || {};
|
||||||
|
const deployedAddresses = deployInfo.addresses || [];
|
||||||
|
|
||||||
|
let successMessage = '✅ TimelockModule успешно задеплоен!\n\n';
|
||||||
|
successMessage += `📊 Детали деплоя:\n`;
|
||||||
|
successMessage += `• DLE: ${dleAddress.value}\n`;
|
||||||
|
successMessage += `• Тип модуля: TimelockModule\n`;
|
||||||
|
successMessage += `• Адрес модуля: ${deployInfo.moduleAddress || 'Не указан'}\n`;
|
||||||
|
|
||||||
|
if (deployedAddresses.length > 0) {
|
||||||
|
successMessage += `\n🌐 Задеплоенные адреса:\n`;
|
||||||
|
deployedAddresses.forEach((addr, index) => {
|
||||||
|
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
|
||||||
|
|
||||||
|
alert(successMessage);
|
||||||
|
|
||||||
// Перенаправляем обратно к модулям
|
// Перенаправляем обратно к модулям
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(`/management/modules?address=${dleAddress.value}`);
|
router.push(`/management/modules?address=${dleAddress.value}`);
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Неизвестная ошибка');
|
throw new Error(result.error || 'Неизвестная ошибка');
|
||||||
@@ -569,6 +447,22 @@ onMounted(() => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Секция администратора */
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Дополнительные настройки */
|
/* Дополнительные настройки */
|
||||||
.advanced-settings {
|
.advanced-settings {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -725,6 +619,45 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Сообщение об отсутствии прав доступа */
|
||||||
|
.no-access-message {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 30px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content h3 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
/* Адаптивность */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.info-grid {
|
.info-grid {
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма деплоя модуля во всех сетях -->
|
<!-- Форма деплоя модуля администратором -->
|
||||||
<div class="deploy-form">
|
<div v-if="canManageSettings" class="deploy-form">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h3>🌐 Деплой TreasuryModule во всех сетях</h3>
|
<h3>🌐 Деплой TreasuryModule во всех сетях</h3>
|
||||||
<p>Деплой модуля казначейства во всех 4 сетях одновременно</p>
|
<p>Администратор деплоит модуль во всех 4 сетях одновременно, затем создает предложение для добавления в DLE</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-content">
|
<div class="form-content">
|
||||||
@@ -85,203 +85,52 @@
|
|||||||
<h4>⚙️ Настройки TreasuryModule:</h4>
|
<h4>⚙️ Настройки TreasuryModule:</h4>
|
||||||
|
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
|
<!-- Поля администратора -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h5>🔐 Настройки администратора:</h5>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="emergencyAdmin">Адрес экстренного администратора:</label>
|
<label for="adminPrivateKey">Приватный ключ администратора:</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="password"
|
||||||
id="emergencyAdmin"
|
id="adminPrivateKey"
|
||||||
v-model="moduleSettings.emergencyAdmin"
|
v-model="moduleSettings.adminPrivateKey"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="0x..."
|
placeholder="0x..."
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<small class="form-help">Адрес экстренного администратора для управления модулем</small>
|
<small class="form-help">Приватный ключ для деплоя модуля (администратор платит газ)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="chainId">ID сети:</label>
|
<label for="etherscanApiKey">Etherscan API ключ:</label>
|
||||||
<select
|
|
||||||
id="chainId"
|
|
||||||
v-model="moduleSettings.chainId"
|
|
||||||
class="form-control"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="11155111">Sepolia (11155111)</option>
|
|
||||||
<option value="17000">Holesky (17000)</option>
|
|
||||||
<option value="421614">Arbitrum Sepolia (421614)</option>
|
|
||||||
<option value="84532">Base Sepolia (84532)</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">ID сети для деплоя модуля</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="defaultDelay">Стандартная задержка (часы):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="defaultDelay"
|
|
||||||
v-model="moduleSettings.defaultDelay"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="720"
|
|
||||||
placeholder="24"
|
|
||||||
>
|
|
||||||
<small class="form-help">Стандартная задержка для операций (1-720 часов)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="emergencyDelay">Экстренная задержка (минуты):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="emergencyDelay"
|
|
||||||
v-model="moduleSettings.emergencyDelay"
|
|
||||||
class="form-control"
|
|
||||||
min="5"
|
|
||||||
max="1440"
|
|
||||||
placeholder="30"
|
|
||||||
>
|
|
||||||
<small class="form-help">Экстренная задержка для критических операций (5-1440 минут)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="supportedTokens">Поддерживаемые токены (адреса через запятую):</label>
|
|
||||||
<textarea
|
|
||||||
id="supportedTokens"
|
|
||||||
v-model="moduleSettings.supportedTokens"
|
|
||||||
class="form-control"
|
|
||||||
rows="3"
|
|
||||||
placeholder="0x1234..., 0x5678..., 0x9abc..."
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Адреса ERC20 токенов, которые будет поддерживать казначейство (через запятую)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="gasPaymentTokens">Токены для оплаты газа (адреса через запятую):</label>
|
|
||||||
<textarea
|
|
||||||
id="gasPaymentTokens"
|
|
||||||
v-model="moduleSettings.gasPaymentTokens"
|
|
||||||
class="form-control"
|
|
||||||
rows="2"
|
|
||||||
placeholder="0x1234..., 0x5678..."
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Токены, которыми можно оплачивать газ (через запятую)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Дополнительные настройки казны -->
|
|
||||||
<div class="advanced-settings">
|
|
||||||
<h5>🔧 Дополнительные настройки казны:</h5>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="paymasterAddress">Адрес Paymaster:</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="paymasterAddress"
|
id="etherscanApiKey"
|
||||||
v-model="moduleSettings.paymasterAddress"
|
v-model="moduleSettings.etherscanApiKey"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="0x..."
|
placeholder="YourAPIKey..."
|
||||||
>
|
>
|
||||||
<small class="form-help">Адрес Paymaster для ERC-4337 (оплата газа любым токеном)</small>
|
<small class="form-help">API ключ для автоматической верификации контрактов</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="maxBatchTransfers">Максимум batch переводов:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="maxBatchTransfers"
|
|
||||||
v-model="moduleSettings.maxBatchTransfers"
|
|
||||||
class="form-control"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
placeholder="50"
|
|
||||||
>
|
|
||||||
<small class="form-help">Максимальное количество переводов в batch операции (1-100)</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="simple-info">
|
||||||
<div class="form-group">
|
<h5>📋 Информация о TreasuryModule:</h5>
|
||||||
<label for="gasTokenRates">Курсы токенов для газа (JSON формат):</label>
|
<div class="info-text">
|
||||||
<textarea
|
<p><strong>TreasuryModule</strong> будет задеплоен с настройками по умолчанию:</p>
|
||||||
id="gasTokenRates"
|
<ul>
|
||||||
v-model="moduleSettings.gasTokenRates"
|
<li>✅ Поддержка ETH и основных ERC20 токенов</li>
|
||||||
class="form-control"
|
<li>✅ Стандартные задержки для безопасности</li>
|
||||||
rows="3"
|
<li>✅ Автоматическая настройка для всех сетей</li>
|
||||||
placeholder='{"0x1234...": "1000000000000000000", "0x5678...": "2000000000000000000"}'
|
<li>✅ Готовые настройки безопасности</li>
|
||||||
></textarea>
|
</ul>
|
||||||
<small class="form-help">Курсы обмена токенов на нативную монету (JSON формат)</small>
|
<p><em>После деплоя настройки можно будет изменить через governance.</em></p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="emergencyThreshold">Порог экстренных операций (ETH):</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="emergencyThreshold"
|
|
||||||
v-model="moduleSettings.emergencyThreshold"
|
|
||||||
class="form-control"
|
|
||||||
min="0"
|
|
||||||
step="0.001"
|
|
||||||
placeholder="1.0"
|
|
||||||
>
|
|
||||||
<small class="form-help">Порог для экстренных операций в ETH</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="initialTokens">Начальные токены для добавления (JSON формат):</label>
|
|
||||||
<textarea
|
|
||||||
id="initialTokens"
|
|
||||||
v-model="moduleSettings.initialTokens"
|
|
||||||
class="form-control"
|
|
||||||
rows="4"
|
|
||||||
placeholder='[{"address": "0x1234...", "symbol": "USDC", "decimals": 6}, {"address": "0x5678...", "symbol": "USDT", "decimals": 6}]'
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Токены для автоматического добавления при деплое (JSON массив)</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="autoRefreshBalances">Автообновление балансов:</label>
|
|
||||||
<select
|
|
||||||
id="autoRefreshBalances"
|
|
||||||
v-model="moduleSettings.autoRefreshBalances"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<option value="true">Включено</option>
|
|
||||||
<option value="false">Отключено</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">Автоматическое обновление балансов токенов</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="batchTransferEnabled">Batch переводы включены:</label>
|
|
||||||
<select
|
|
||||||
id="batchTransferEnabled"
|
|
||||||
v-model="moduleSettings.batchTransferEnabled"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<option value="true">Включено</option>
|
|
||||||
<option value="false">Отключено</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-help">Разрешить batch операции переводов</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="treasuryDescription">Описание казны:</label>
|
|
||||||
<textarea
|
|
||||||
id="treasuryDescription"
|
|
||||||
v-model="moduleSettings.treasuryDescription"
|
|
||||||
class="form-control"
|
|
||||||
rows="2"
|
|
||||||
placeholder="Описание казны DLE для управления финансами..."
|
|
||||||
></textarea>
|
|
||||||
<small class="form-help">Описание казны для документации</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,12 +139,17 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-large deploy-module"
|
class="btn btn-primary btn-large deploy-module"
|
||||||
@click="deployTreasuryModule"
|
@click="deployTreasuryModule"
|
||||||
:disabled="isDeploying || !dleAddress"
|
:disabled="isDeploying || !dleAddress || !isFormValid"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
<i class="fas fa-rocket" :class="{ 'fa-spin': isDeploying }"></i>
|
||||||
{{ isDeploying ? 'Деплой модуля...' : 'Деплой TreasuryModule' }}
|
{{ isDeploying ? 'Деплой во всех сетях...' : 'Деплой TreasuryModule во всех сетях' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!isFormValid && !isDeploying" class="form-validation-info">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>Заполните приватный ключ и API ключ для деплоя</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="deploymentProgress" class="deployment-progress">
|
<div v-if="deploymentProgress" class="deployment-progress">
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ deploymentProgress.message }}</span>
|
<span>{{ deploymentProgress.message }}</span>
|
||||||
@@ -309,14 +163,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Сообщение для пользователей без прав доступа -->
|
||||||
|
<div v-if="!canManageSettings" class="no-access-message">
|
||||||
|
<div class="message-content">
|
||||||
|
<h3>🔒 Нет прав доступа</h3>
|
||||||
|
<p>У вас нет прав для деплоя смарт-контрактов. Только пользователи с ролью Editor могут выполнять деплой.</p>
|
||||||
|
<button class="btn btn-secondary" @click="router.push('/management/modules')">
|
||||||
|
← Вернуться к модулям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, ref, onMounted } from 'vue';
|
import { defineProps, defineEmits, ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import BaseLayout from '../../../components/BaseLayout.vue';
|
import BaseLayout from '../../../components/BaseLayout.vue';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
// Определяем props
|
// Определяем props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -331,6 +197,7 @@ const emit = defineEmits(['auth-action-completed']);
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { canEdit, canManageSettings } = usePermissions();
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
@@ -340,29 +207,23 @@ const deploymentProgress = ref(null);
|
|||||||
|
|
||||||
// Настройки модуля
|
// Настройки модуля
|
||||||
const moduleSettings = ref({
|
const moduleSettings = ref({
|
||||||
// Основные параметры
|
// Поля администратора
|
||||||
emergencyAdmin: '',
|
adminPrivateKey: '',
|
||||||
chainId: 11155111,
|
etherscanApiKey: ''
|
||||||
defaultDelay: 24, // hours
|
});
|
||||||
emergencyDelay: 30, // minutes
|
|
||||||
|
|
||||||
// Токены
|
// Проверка валидности формы
|
||||||
supportedTokens: '',
|
const isFormValid = computed(() => {
|
||||||
gasPaymentTokens: '',
|
return moduleSettings.value.adminPrivateKey && moduleSettings.value.etherscanApiKey;
|
||||||
initialTokens: '',
|
|
||||||
|
|
||||||
// Дополнительные настройки
|
|
||||||
paymasterAddress: '',
|
|
||||||
maxBatchTransfers: 50,
|
|
||||||
gasTokenRates: '',
|
|
||||||
emergencyThreshold: 1.0,
|
|
||||||
autoRefreshBalances: 'true',
|
|
||||||
batchTransferEnabled: 'true',
|
|
||||||
treasuryDescription: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция деплоя TreasuryModule
|
// Функция деплоя TreasuryModule
|
||||||
async function deployTreasuryModule() {
|
async function deployTreasuryModule() {
|
||||||
|
if (!canManageSettings.value) {
|
||||||
|
alert('У вас нет прав для деплоя смарт-контрактов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isDeploying.value = true;
|
isDeploying.value = true;
|
||||||
deploymentProgress.value = {
|
deploymentProgress.value = {
|
||||||
@@ -372,8 +233,8 @@ async function deployTreasuryModule() {
|
|||||||
|
|
||||||
console.log('[TreasuryModuleDeployView] Начинаем деплой TreasuryModule для DLE:', dleAddress.value);
|
console.log('[TreasuryModuleDeployView] Начинаем деплой TreasuryModule для DLE:', dleAddress.value);
|
||||||
|
|
||||||
// Вызываем API для деплоя модуля во всех сетях
|
// Вызываем API для деплоя модуля администратором
|
||||||
const response = await fetch('/api/dle-modules/deploy-treasury', {
|
const response = await fetch('/api/dle-modules/deploy-treasury-admin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -381,26 +242,11 @@ async function deployTreasuryModule() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
dleAddress: dleAddress.value,
|
dleAddress: dleAddress.value,
|
||||||
moduleType: 'treasury',
|
moduleType: 'treasury',
|
||||||
|
adminPrivateKey: moduleSettings.value.adminPrivateKey,
|
||||||
|
etherscanApiKey: moduleSettings.value.etherscanApiKey,
|
||||||
settings: {
|
settings: {
|
||||||
// Основные параметры
|
// Используем настройки по умолчанию
|
||||||
emergencyAdmin: moduleSettings.value.emergencyAdmin,
|
useDefaultSettings: true
|
||||||
chainId: moduleSettings.value.chainId,
|
|
||||||
defaultDelay: moduleSettings.value.defaultDelay,
|
|
||||||
emergencyDelay: moduleSettings.value.emergencyDelay,
|
|
||||||
|
|
||||||
// Токены
|
|
||||||
supportedTokens: moduleSettings.value.supportedTokens.split(',').map(addr => addr.trim()).filter(addr => addr),
|
|
||||||
gasPaymentTokens: moduleSettings.value.gasPaymentTokens.split(',').map(addr => addr.trim()).filter(addr => addr),
|
|
||||||
initialTokens: moduleSettings.value.initialTokens ? JSON.parse(moduleSettings.value.initialTokens) : [],
|
|
||||||
|
|
||||||
// Дополнительные настройки
|
|
||||||
paymasterAddress: moduleSettings.value.paymasterAddress,
|
|
||||||
maxBatchTransfers: parseInt(moduleSettings.value.maxBatchTransfers),
|
|
||||||
gasTokenRates: moduleSettings.value.gasTokenRates ? JSON.parse(moduleSettings.value.gasTokenRates) : {},
|
|
||||||
emergencyThreshold: parseFloat(moduleSettings.value.emergencyThreshold),
|
|
||||||
autoRefreshBalances: moduleSettings.value.autoRefreshBalances === 'true',
|
|
||||||
batchTransferEnabled: moduleSettings.value.batchTransferEnabled === 'true',
|
|
||||||
treasuryDescription: moduleSettings.value.treasuryDescription
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -420,12 +266,31 @@ async function deployTreasuryModule() {
|
|||||||
percentage: 100
|
percentage: 100
|
||||||
};
|
};
|
||||||
|
|
||||||
alert('✅ Деплой TreasuryModule запущен во всех сетях!');
|
// Показываем детальную информацию о деплое
|
||||||
|
const deployInfo = result.data || {};
|
||||||
|
const deployedAddresses = deployInfo.addresses || [];
|
||||||
|
|
||||||
|
let successMessage = '✅ TreasuryModule успешно задеплоен во всех сетях!\n\n';
|
||||||
|
successMessage += `📊 Детали деплоя:\n`;
|
||||||
|
successMessage += `• DLE: ${dleAddress.value}\n`;
|
||||||
|
successMessage += `• Тип модуля: TreasuryModule\n`;
|
||||||
|
successMessage += `• Сети: Sepolia, Holesky, Arbitrum Sepolia, Base Sepolia\n`;
|
||||||
|
|
||||||
|
if (deployedAddresses.length > 0) {
|
||||||
|
successMessage += `\n🌐 Задеплоенные адреса:\n`;
|
||||||
|
deployedAddresses.forEach((addr, index) => {
|
||||||
|
successMessage += `${index + 1}. ${addr.network}: ${addr.address}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage += `\n📝 Следующий шаг: Создайте предложение для добавления модуля в DLE через governance.`;
|
||||||
|
|
||||||
|
alert(successMessage);
|
||||||
|
|
||||||
// Перенаправляем обратно к модулям
|
// Перенаправляем обратно к модулям
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(`/management/modules?address=${dleAddress.value}`);
|
router.push(`/management/modules?address=${dleAddress.value}`);
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Неизвестная ошибка');
|
throw new Error(result.error || 'Неизвестная ошибка');
|
||||||
@@ -583,6 +448,176 @@ onMounted(() => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Секция администратора */
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Простая информация */
|
||||||
|
.simple-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-info h5 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input.is-valid {
|
||||||
|
border-color: #28a745;
|
||||||
|
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input.is-invalid {
|
||||||
|
border-color: #dc3545;
|
||||||
|
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-icon.valid {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-icon.invalid {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-icon.loading {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-token {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dc3545;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-token:hover {
|
||||||
|
background: #c82333;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-token:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-token {
|
||||||
|
margin-top: 15px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: 2px dashed #28a745;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #28a745;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-token:hover {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-color: #28a745;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сообщение валидации */
|
||||||
|
.form-validation-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-validation-info i {
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
/* Дополнительные настройки */
|
/* Дополнительные настройки */
|
||||||
.advanced-settings {
|
.advanced-settings {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -797,6 +832,45 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Сообщение об отсутствии прав доступа */
|
||||||
|
.no-access-message {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 30px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content h3 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .btn:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
/* Адаптивность */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.info-grid {
|
.info-grid {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<div class="create-table-container">
|
<div class="create-table-container">
|
||||||
<h2>Создать новую таблицу</h2>
|
<h2>Создать новую таблицу</h2>
|
||||||
<form v-if="isAdmin" @submit.prevent="handleCreateTable" class="create-table-form">
|
<form v-if="canEdit" @submit.prevent="handleCreateTable" class="create-table-form">
|
||||||
<label>Название таблицы</label>
|
<label>Название таблицы</label>
|
||||||
<input v-model="newTableName" required placeholder="Введите название" />
|
<input v-model="newTableName" required placeholder="Введите название" />
|
||||||
<label>Описание</label>
|
<label>Описание</label>
|
||||||
@@ -29,7 +29,10 @@
|
|||||||
<button type="button" @click="goBack">Отмена</button>
|
<button type="button" @click="goBack">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div v-else class="empty-table-placeholder">Нет прав для создания таблицы</div>
|
<div v-else class="empty-table-placeholder">
|
||||||
|
<p>Нет прав для создания таблицы</p>
|
||||||
|
<button type="button" @click="goBack" class="btn btn-primary">Назад</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -40,12 +43,14 @@ import { useRouter } from 'vue-router';
|
|||||||
import BaseLayout from '../../components/BaseLayout.vue';
|
import BaseLayout from '../../components/BaseLayout.vue';
|
||||||
import tablesService from '../../services/tablesService';
|
import tablesService from '../../services/tablesService';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const newTableName = ref('');
|
const newTableName = ref('');
|
||||||
const newTableDescription = ref('');
|
const newTableDescription = ref('');
|
||||||
const newTableIsRagSourceId = ref(2);
|
const newTableIsRagSourceId = ref(2);
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
|
|
||||||
async function handleCreateTable() {
|
async function handleCreateTable() {
|
||||||
if (!newTableName.value) return;
|
if (!newTableName.value) return;
|
||||||
@@ -128,4 +133,30 @@ function goBack() {
|
|||||||
.form-actions button[type="button"]:hover {
|
.form-actions button[type="button"]:hover {
|
||||||
background: #d5d5d5;
|
background: #d5d5d5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-table-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table-placeholder p {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table-placeholder .btn {
|
||||||
|
background: #2ecc40;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5em 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table-placeholder .btn:hover {
|
||||||
|
background: #27ae38;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
<h2>Удалить таблицу?</h2>
|
<h2>Удалить таблицу?</h2>
|
||||||
<p>Вы уверены, что хотите удалить эту таблицу? Это действие необратимо.</p>
|
<p>Вы уверены, что хотите удалить эту таблицу? Это действие необратимо.</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button v-if="isAdmin" class="danger" @click="remove">Удалить</button>
|
<button v-if="canDelete" class="danger" @click="remove">Удалить</button>
|
||||||
<button @click="cancel">Отмена</button>
|
<button @click="cancel">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isAdmin" class="empty-table-placeholder">Нет прав для удаления таблицы</div>
|
<div v-if="!canDelete" class="empty-table-placeholder">Нет прав для удаления таблицы</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -28,9 +28,11 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import BaseLayout from '../../components/BaseLayout.vue';
|
import BaseLayout from '../../components/BaseLayout.vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
const $route = useRoute();
|
const $route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canDelete } = usePermissions();
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
await axios.delete(`/tables/${$route.params.id}`);
|
await axios.delete(`/tables/${$route.params.id}`);
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
<button class="nav-btn" @click="goToTables">Таблицы</button>
|
<button class="nav-btn" @click="goToTables">Таблицы</button>
|
||||||
<button class="nav-btn" @click="goToCreate">Создать таблицу</button>
|
<button class="nav-btn" @click="goToCreate">Создать таблицу</button>
|
||||||
<button class="close-btn" @click="closeTable">Закрыть</button>
|
<button class="close-btn" @click="closeTable">Закрыть</button>
|
||||||
<button v-if="isAdmin" class="action-btn" @click="goToEdit">Редактировать</button>
|
<button v-if="canEdit" class="action-btn" @click="goToEdit">Редактировать</button>
|
||||||
<button v-if="isAdmin" class="danger-btn" @click="goToDelete">Удалить</button>
|
<button v-if="canDelete" class="danger-btn" @click="goToDelete">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
<UserTableView v-if="isAdmin" :table-id="Number($route.params.id)" />
|
<UserTableView v-if="canRead" :table-id="Number($route.params.id)" />
|
||||||
<div v-else class="empty-table-placeholder">Нет данных для отображения</div>
|
<div v-else class="empty-table-placeholder">Нет данных для отображения</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -31,9 +31,11 @@ import BaseLayout from '../../components/BaseLayout.vue';
|
|||||||
import UserTableView from '../../components/tables/UserTableView.vue';
|
import UserTableView from '../../components/tables/UserTableView.vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
const $route = useRoute();
|
const $route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canRead, canEdit, canDelete } = usePermissions();
|
||||||
|
|
||||||
function closeTable() {
|
function closeTable() {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="tables-list-block">
|
<div class="tables-list-block">
|
||||||
<button class="close-btn" @click="goBack">×</button>
|
<button class="close-btn" @click="goBack">×</button>
|
||||||
<h2>Список таблиц</h2>
|
<h2>Список таблиц</h2>
|
||||||
<UserTablesList v-if="isAdmin" />
|
<UserTablesList v-if="canRead" />
|
||||||
<div v-else class="empty-table-placeholder">Нет данных для отображения</div>
|
<div v-else class="empty-table-placeholder">Нет данных для отображения</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -26,8 +26,10 @@ import BaseLayout from '../../components/BaseLayout.vue';
|
|||||||
import UserTablesList from '../../components/tables/UserTablesList.vue';
|
import UserTablesList from '../../components/tables/UserTablesList.vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthContext } from '@/composables/useAuth';
|
import { useAuthContext } from '@/composables/useAuth';
|
||||||
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAdmin } = useAuthContext();
|
const { isAdmin } = useAuthContext();
|
||||||
|
const { canRead } = usePermissions();
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'crm' });
|
router.push({ name: 'crm' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user