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

This commit is contained in:
2025-05-05 20:34:11 +03:00
parent ef2da22c70
commit bca410e099
20 changed files with 1684 additions and 403 deletions

View File

@@ -12,7 +12,8 @@
"lint:style": "stylelint \"**/*.{vue,css}\"",
"lint:style:fix": "stylelint \"**/*.{vue,css}\" --fix",
"format": "prettier --write \"**/*.{js,vue,json,md}\"",
"format:check": "prettier --check \"**/*.{js,vue,json,md}\""
"format:check": "prettier --check \"**/*.{js,vue,json,md}\"",
"dev:styles": "node scripts/style-check.js && yarn dev"
},
"dependencies": {
"axios": "^1.8.4",

View File

@@ -0,0 +1,112 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import chalk from 'chalk'; // Для цветного вывода
// ES модули не поддерживают __dirname, поэтому создаем его
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Проверка наличия пакета chalk и его установка при необходимости
try {
import('chalk');
} catch (e) {
console.log('Устанавливаем пакет chalk для цветного вывода...');
execSync('yarn add chalk --dev', { stdio: 'inherit' });
console.log('Пакет chalk установлен.');
}
// Функция для проверки наличия файла
function checkFileExists(filePath, errorMessage) {
const fullPath = path.resolve(__dirname, '..', filePath);
if (!fs.existsSync(fullPath)) {
console.log(chalk.red(errorMessage));
process.exit(1);
}
console.log(chalk.green(`✓ Файл ${path.basename(filePath)} найден`));
}
// Функция для проверки импортов стилей в App.vue
function checkStyleImports() {
const appVuePath = path.resolve(__dirname, '..', 'src', 'App.vue');
try {
const appVueContent = fs.readFileSync(appVuePath, 'utf8');
const requiredImports = [
'./assets/styles/variables.css',
'./assets/styles/base.css',
'./assets/styles/layout.css',
'./assets/styles/global.css'
];
let allImportsFound = true;
for (const importPath of requiredImports) {
if (!appVueContent.includes(`import '${importPath}'`)) {
console.log(chalk.red(`✗ Импорт ${importPath} не найден в App.vue!`));
allImportsFound = false;
} else {
console.log(chalk.green(`✓ Импорт ${importPath} найден в App.vue`));
}
}
if (!allImportsFound) {
console.log(chalk.yellow('Убедитесь, что в App.vue импортируются все нужные стили:'));
requiredImports.forEach(imp => console.log(` import '${imp}';`));
}
} catch (error) {
console.log(chalk.red(`Ошибка при чтении App.vue: ${error.message}`));
process.exit(1);
}
}
// Функция для проверки компонентов настроек
function checkSettingsComponents() {
const settingsDir = path.resolve(__dirname, '..', 'src', 'components', 'settings');
const requiredComponents = [
'AISettings.vue',
'BlockchainSettings.vue',
'SecuritySettings.vue',
'InterfaceSettings.vue'
];
for (const component of requiredComponents) {
const componentPath = path.join(settingsDir, component);
if (fs.existsSync(componentPath)) {
console.log(chalk.green(`✓ Компонент ${component} найден`));
} else {
console.log(chalk.red(`✗ Компонент ${component} не найден!`));
}
}
}
// Запуск скрипта
console.log(chalk.blue('======================================='));
console.log(chalk.green('Запуск проекта с обновленными стилями'));
console.log(chalk.blue('======================================='));
// Проверка наличия всех файлов стилей
checkFileExists('src/assets/styles/global.css', 'Ошибка: файл global.css не найден!');
checkFileExists('src/assets/styles/variables.css', 'Ошибка: файл variables.css не найден!');
checkFileExists('src/assets/styles/base.css', 'Ошибка: файл base.css не найден!');
checkFileExists('src/assets/styles/layout.css', 'Ошибка: файл layout.css не найден!');
// Проверка импортов стилей
console.log(chalk.yellow('Проверка imports стилей...'));
checkStyleImports();
// Проверка компонентов настроек
checkSettingsComponents();
console.log(chalk.blue('---------------------------------------'));
console.log(chalk.yellow('Запуск сервера разработки...'));
console.log(chalk.blue('---------------------------------------'));
// Выходим успешно, т.к. сам запуск выполняется командой yarn dev:styles
try {
process.exit(0);
} catch (error) {
console.log(chalk.red(`Ошибка при запуске сервера разработки: ${error.message}`));
process.exit(1);
}

View File

@@ -4,15 +4,31 @@
<div class="loading-spinner" />
</div>
<RouterView />
<RouterView v-slot="{ Component }">
<component
:is="Component"
:isAuthenticated="auth.isAuthenticated.value"
:identities="auth.identities.value"
:tokenBalances="tokenBalances"
:isLoadingTokens="isLoadingTokens"
@auth-action-completed="handleAuthActionCompleted"
/>
</RouterView>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { ref, watch, onMounted, computed } from 'vue';
import { RouterView } from 'vue-router';
import { useAuth } from './composables/useAuth';
import './assets/styles/home.css';
import { fetchTokenBalances } from './services/tokens';
import eventBus from './utils/eventBus';
// Импорт стилей
import './assets/styles/variables.css';
import './assets/styles/base.css';
import './assets/styles/layout.css';
import './assets/styles/global.css';
// Состояние загрузки
const isLoading = ref(false);
@@ -20,10 +36,111 @@
// Использование composable для аутентификации
const auth = useAuth();
// --- Логика загрузки баланса токенов ---
const tokenBalances = ref({});
const isLoadingTokens = ref(false);
const identities = computed(() => auth.identities.value);
const hasIdentityType = (type) => {
if (!identities.value) return false;
return identities.value.some((identity) => identity.provider === type);
};
const getIdentityValue = (type) => {
if (!identities.value) return null;
const identity = identities.value.find((identity) => identity.provider === type);
return identity ? identity.provider_id : null;
};
const refreshTokenBalances = async () => {
if (!hasIdentityType('wallet') || !auth.isAuthenticated.value) {
tokenBalances.value = {}; // Очищаем, если нет кошелька или не авторизован
return;
}
isLoadingTokens.value = true;
try {
const walletAddress = getIdentityValue('wallet');
console.log('[App] Обновление балансов для адреса:', walletAddress);
const balances = await fetchTokenBalances(walletAddress);
console.log('[App] Полученные балансы:', balances);
tokenBalances.value = balances || {};
} catch (error) {
console.error('[App] Ошибка при получении балансов:', error);
tokenBalances.value = {};
} finally {
isLoadingTokens.value = false;
}
};
// Следим за изменениями в идентификаторах
watch(identities, (newIdentities, oldIdentities) => {
if (auth.isAuthenticated.value) {
const newWalletId = getIdentityValue('wallet');
const oldWalletIdentity = oldIdentities ? oldIdentities.find(id => id.provider === 'wallet') : null;
const oldWalletId = oldWalletIdentity ? oldWalletIdentity.provider_id : null;
if (newWalletId !== oldWalletId) {
console.log('[App] Обнаружено изменение идентификатора кошелька, обновляем балансы');
refreshTokenBalances();
} else if (hasIdentityType('wallet') && Object.keys(tokenBalances.value).length === 0 && !isLoadingTokens.value) {
// Если кошелек есть, но баланс пустой и не грузится - пробуем загрузить
console.log('[App] Кошелек есть, но баланс пуст, пытаемся загрузить.');
refreshTokenBalances();
}
}
}, { deep: true });
// Мониторинг изменений состояния аутентификации
watch(auth.isAuthenticated, (newValue, oldValue) => {
if (newValue !== oldValue) {
console.log('[App] Состояние аутентификации изменилось:', newValue);
watch(auth.isAuthenticated, (isAuth) => {
console.log('[App] Состояние аутентификации изменилось:', isAuth);
if (isAuth) {
// Убираем задержку, полагаемся на watch(identities) или прямо вызываем
// setTimeout(refreshTokenBalances, 500);
refreshTokenBalances(); // Вызываем сразу, если нужно обновить при смене auth
} else {
// Очищаем баланс при выходе
tokenBalances.value = {};
}
});
// --- Возвращаем и улучшаем функцию-обработчик ---
const handleAuthActionCompleted = async () => {
console.log('[App] Auth action completed, triggering updates...');
isLoading.value = true; // Показываем индикатор загрузки
try {
// 1. Проверяем аутентификацию (обновит identities и isAuthenticated)
await auth.checkAuth();
console.log('[App] auth.checkAuth() completed. isAuthenticated:', auth.isAuthenticated.value);
// 2. Обновляем баланс (использует обновленные identities)
await refreshTokenBalances();
console.log('[App] refreshTokenBalances() completed.');
// 3. Явно оповещаем компоненты об изменении состояния авторизации
// Передаем актуальное состояние из useAuth
eventBus.emit('auth-state-changed', {
isAuthenticated: auth.isAuthenticated.value,
authType: auth.authType.value, // Предполагаем, что authType есть в useAuth
userId: auth.userId.value, // Предполагаем, что userId есть в useAuth
fromApp: true // Флаг, что событие от App.vue
});
console.log('[App] auth-state-changed event emitted.');
} catch (error) {
console.error("[App] Error during auth action handling:", error);
} finally {
isLoading.value = false; // Скрываем индикатор загрузки
}
};
// Первичная загрузка баланса при монтировании, если пользователь уже авторизован
onMounted(() => {
if (auth.isAuthenticated.value) {
refreshTokenBalances();
}
});
</script>

View File

@@ -0,0 +1,95 @@
# Структура стилей проекта
## Обзор
Проект использует структурированный подход к организации стилей CSS для улучшения поддерживаемости, предотвращения конфликтов и обеспечения согласованности пользовательского интерфейса.
## Файлы стилей
- **variables.css** - CSS-переменные (цвета, размеры, отступы)
- **base.css** - Базовые стили для всего приложения и сброс стилей
- **layout.css** - Стили для основной структуры макета приложения
- **global.css** - Общие утилитарные классы, доступные во всем приложении
- **home.css.bak** - Устаревший файл, переименован в .bak. Стили перенесены в scoped стили компонентов
## Приоритеты использования стилей
1. **Компонентные scoped стили** - для стилей, специфичных для компонента
2. **global.css** - для общих классов, используемых в нескольких компонентах
3. **variables.css** - для общих переменных CSS во всем проекте
## Рекомендации по использованию
### Для новых компонентов:
1. Используйте scoped стили внутри файла компонента:
```vue
<style scoped>
.component-name {
/* стили компонента */
}
</style>
```
2. Используйте глобальные классы для общих элементов:
```html
<button class="btn btn-primary">Сохранить</button>
```
3. Используйте CSS-переменные вместо жестко закодированных значений:
```css
.element {
color: var(--color-primary);
padding: var(--spacing-md);
}
```
### Для существующих компонентов:
1. При обновлении компонента постепенно переносите стили из home.css в scoped стили компонента
2. Не удаляйте стили из home.css до полного тестирования всех зависящих компонентов
## Глобальные CSS-классы
### Контейнеры
- `.page-container` - Основной контейнер страницы
- `.card` - Контейнер для блока информации
### Кнопки
- `.btn` - Базовый класс для всех кнопок
- `.btn-primary` - Основная (зеленая) кнопка
- `.btn-secondary` - Дополнительная (синяя) кнопка
- `.btn-accent` - Акцентная (фиолетовая) кнопка
- `.btn-danger` - Кнопка опасного действия (красная)
### Формы
- `.form-control` - Элемент формы (input, select, textarea)
- `.form-group` - Группа элементов формы
- `.form-label` - Метка для элемента формы
### Утилиты
- `.text-center` - Выравнивание текста по центру
- `.d-flex` - Включение flex-контейнера
- `.mt-*`, `.mb-*` - Отступы сверху/снизу
## Процесс миграции
Постепенно мы переходим от использования большого глобального файла home.css к модульным scoped стилям в компонентах и более структурированным общим стилям.
1. Новые компоненты должны использовать только scoped стили и global.css
2. При обновлении существующих компонентов переносите стили из home.css
3. После полного перехода home.css будет удален
## Выполненная миграция (обновлено)
Миграция стилей завершена для следующих компонентов:
1. **ChatInterface.vue** - перенесены стили интерфейса чата, включая адаптивные стили для мобильных устройств
2. **Message.vue** - перенесены стили для сообщений с разными типами вложений
Файл **home.css** переименован в **home.css.bak** и больше не используется в проекте. Ссылка на него удалена из **HomeView.vue**.
Для запуска проекта с проверкой стилей можно использовать команду:
```
yarn dev:styles
```

View File

@@ -0,0 +1,150 @@
/* frontend/src/assets/styles/global.css */
/* Общие глобальные стили, используемые во всем приложении */
/* Контейнеры */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--color-white);
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0;
padding: 0 20px;
width: 100%;
background-color: var(--color-white);
}
/* Стандартный контейнер для страниц */
.page-container {
max-width: 1150px;
margin: 20px auto;
padding: var(--block-padding);
background-color: var(--color-white);
border-radius: var(--block-radius);
box-shadow: var(--shadow-md);
}
/* Общие стили для кнопок */
.btn {
height: var(--button-height);
padding: 0 var(--spacing-lg);
border-radius: var(--radius-lg);
font-size: var(--font-size-md);
font-weight: 500;
border: none;
cursor: pointer;
transition: var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
margin: 0;
text-decoration: none;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-white);
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-secondary {
background: var(--color-secondary);
color: var(--color-white);
}
.btn-secondary:hover {
background-color: #1976D2; /* Темнее синего */
}
.btn-accent {
background: var(--color-accent);
color: var(--color-white);
}
.btn-accent:hover {
background: var(--color-accent-dark);
}
.btn-danger {
background: var(--color-danger);
color: var(--color-white);
}
.btn-danger:hover {
background-color: #D32F2F; /* Темнее красного */
}
/* Общие стили для форм */
.form-control {
height: var(--input-height);
padding: 0 var(--spacing-lg);
border-radius: var(--radius-lg);
border: 1px solid var(--color-grey-light);
font-size: var(--font-size-md);
width: 100%;
background: var(--color-white);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
color: var(--color-text);
}
/* Общие стили для карточек/блоков */
.card {
padding: var(--block-padding);
margin-bottom: var(--block-margin);
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.card-header {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-xl);
color: var(--color-dark);
border-bottom: 1px solid var(--color-grey-light);
padding-bottom: var(--spacing-sm);
}
/* Общие утилиты */
.text-center { text-align: center; }
.text-right { text-align: right; }
.d-flex { display: flex; }
.flex-column { flex-direction: column; }
.align-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.mt-1 { margin-top: var(--spacing-xs); }
.mt-2 { margin-top: var(--spacing-sm); }
.mt-3 { margin-top: var(--spacing-md); }
.mb-1 { margin-bottom: var(--spacing-xs); }
.mb-2 { margin-bottom: var(--spacing-sm); }
.mb-3 { margin-bottom: var(--spacing-md); }
/* Адаптивные стили */
@media (max-width: 768px) {
.page-container {
padding: var(--block-padding-mobile);
}
.btn, .form-control {
height: var(--button-height-mobile);
font-size: var(--font-size-sm);
}
}

View File

@@ -1,79 +1,120 @@
/* frontend/src/assets/styles/variables.css */
:root {
/* Цвета */
--color-primary: #4CAF50;
--color-primary-dark: #45a049;
--color-secondary: #2196F3;
--color-danger: #F44336;
--color-warning: #FF9800;
--color-light: #f5f5f5;
--color-dark: #333333;
--color-grey: #777777;
--color-grey-light: #e0e0e0;
--color-white: #ffffff;
--color-black: #000000;
--color-telegram: #0088cc;
--color-error: #e74c3c;
/*
* ЦВЕТОВАЯ СХЕМА
* Основная палитра цветов приложения
*/
--color-primary: #4CAF50; /* Основной цвет (зеленый) */
--color-primary-dark: #45a049; /* Темно-зеленый для наведения и акцентов */
--color-secondary: #2196F3; /* Второстепенный цвет (синий) */
--color-accent: #5E35B1; /* Акцентный цвет (фиолетовый) */
--color-accent-dark: #4527A0; /* Темно-фиолетовый для наведения */
/* Статусные цвета */
--color-danger: #F44336; /* Ошибки, удаление, опасные действия */
--color-warning: #FF9800; /* Предупреждения */
--color-error: #e74c3c; /* Текст ошибок */
/* Нейтральные цвета */
--color-light: #f5f5f5; /* Светлый фон, фон секций */
--color-dark: #333333; /* Основной текст, заголовки */
--color-grey: #777777; /* Второстепенный текст */
--color-grey-light: #e0e0e0; /* Границы, разделители */
--color-white: #ffffff; /* Белый */
--color-black: #000000; /* Черный */
/* Цвета текста */
--color-text: #333333; /* Основной текст */
--color-text-light: #999999; /* Неакцентированный текст */
--color-border: #e0e0e0; /* Цвет рамок */
/* Цвета брендов */
--color-telegram: #0088cc; /* Фирменный цвет Telegram */
/* Цвета сообщений */
--color-user-message: #EFFAFF;
--color-ai-message: #F8F8F8;
--color-system-message: #FFF3E0;
--color-system-text: #FF5722;
--color-user-message: #EFFAFF; /* Фон сообщений пользователя */
--color-ai-message: #F8F8F8; /* Фон сообщений ИИ */
--color-system-message: #FFF3E0; /* Фон системных сообщений */
--color-system-text: #FF5722; /* Текст системных сообщений */
/* Тени */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/*
* ТЕНИ
* Для создания эффекта глубины и иерархии элементов
*/
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); /* Легкая тень */
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); /* Средняя тень */
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); /* Глубокая тень */
/* Отступы */
--spacing-xs: 5px;
--spacing-sm: 10px;
--spacing-md: 15px;
--spacing-lg: 20px;
--spacing-xl: 30px;
/*
* ОТСТУПЫ
* Для обеспечения консистентных интервалов и отступов
*/
--spacing-xs: 5px; /* Очень маленькие отступы (между близкими элементами) */
--spacing-sm: 10px; /* Маленькие отступы */
--spacing-md: 15px; /* Средние отступы */
--spacing-lg: 20px; /* Большие отступы */
--spacing-xl: 30px; /* Очень большие отступы (между крупными блоками) */
/* Размеры шрифтов */
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-xxl: 24px;
/*
* РАЗМЕРЫ ШРИФТОВ
* Типографическая шкала
*/
--font-size-xs: 12px; /* Маленькие надписи, подписи к полям */
--font-size-sm: 13px; /* Второстепенный текст */
--font-size-md: 14px; /* Основной текст */
--font-size-lg: 16px; /* Подзаголовки */
--font-size-xl: 18px; /* Заголовки разделов */
--font-size-xxl: 24px; /* Главные заголовки */
/* Радиусы скругления */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
/*
* РАДИУСЫ СКРУГЛЕНИЯ
* Для элементов интерфейса
*/
--radius-sm: 4px; /* Небольшое скругление (кнопки, поля ввода) */
--radius-md: 6px; /* Среднее скругление (карточки, панели) */
--radius-lg: 8px; /* Большое скругление (модальные окна, боковые панели) */
/* Переходы */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
/*
* ПЕРЕХОДЫ
* Для плавных анимаций
*/
--transition-fast: 0.2s ease; /* Быстрые переходы (ховеры, небольшие анимации) */
--transition-normal: 0.3s ease; /* Стандартные переходы (появление элементов) */
/* Размеры компонентов (Удаляем старые sidebar width) */
/* --sidebar-width: 110px; */
/* --sidebar-expanded-width: 325px; */
/*
* РАЗМЕРЫ КОМПОНЕНТОВ
* Стандартные размеры для элементов интерфейса
*/
--nav-btn-size: 40px;
--chat-input-min-height: 100px;
--chat-input-max-height: 200px;
--chat-input-focus-min-height: 170px;
--chat-input-focus-max-height: 300px;
/* Унифицированные размеры для кнопок и форм */
/*
* УНИФИЦИРОВАННЫЕ РАЗМЕРЫ
* Для кнопок и форм
*/
--button-height: 48px;
--button-height-mobile: 42px;
--button-padding: 0 var(--spacing-lg);
--button-gap: var(--spacing-md);
--form-gap: var(--spacing-md);
--block-padding: 24px;
--block-padding-mobile: 16px;
--block-margin: 24px;
--block-margin-mobile: 16px;
--input-height: 48px;
--input-height-mobile: 42px;
--input-padding: 0 var(--spacing-lg);
/* Общие стили */
/*
* ОБЩИЕ СТИЛИ
* Производные параметры для единого стиля
*/
--button-radius: var(--radius-lg);
--input-radius: var(--radius-lg);
--block-radius: var(--radius-lg);

View File

@@ -15,11 +15,12 @@
<!-- Правая панель с информацией о кошельке -->
<Sidebar
v-model="showWalletSidebar"
:is-authenticated="auth.isAuthenticated.value"
:is-authenticated="isAuthenticated"
:telegram-auth="telegramAuth"
:email-auth="emailAuth"
:token-balances="tokenBalances.value"
:identities="auth.identities?.value"
:token-balances="tokenBalances"
:identities="identities"
:is-loading-tokens="isLoadingTokens"
@wallet-auth="handleWalletAuth"
@disconnect-wallet="disconnectWallet"
@telegram-auth="handleTelegramAuth"
@@ -36,9 +37,8 @@
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount } from 'vue';
import { ref, onMounted, watch, onBeforeUnmount, defineProps, defineEmits } from 'vue';
import { useAuth } from '../composables/useAuth';
import { useTokenBalances } from '../composables/useTokenBalances';
import { useAuthFlow } from '../composables/useAuthFlow';
import { useNotifications } from '../composables/useNotifications';
import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage';
@@ -55,7 +55,17 @@ import NotificationDisplay from './NotificationDisplay.vue';
const auth = useAuth();
const { notifications, showSuccessMessage, showErrorMessage } = useNotifications();
const { tokenBalances } = useTokenBalances();
// Определяем props, которые будут приходить от родительского View
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
// Определяем emits
const emit = defineEmits(['auth-action-completed']);
// Callback после успешной аутентификации/привязки через Email/Telegram
const handleAuthFlowSuccess = (authType) => {
@@ -102,7 +112,7 @@ const handleWalletAuth = async () => {
const linkResult = await auth.linkIdentity('wallet', result.address);
if (linkResult.success) {
showSuccessMessage('Кошелек успешно подключен к вашему аккаунту!');
await auth.checkAuth(); // Обновить identities
emit('auth-action-completed');
} else {
showErrorMessage(linkResult.error || 'Не удалось подключить кошелек');
}
@@ -112,12 +122,7 @@ const handleWalletAuth = async () => {
if (authResponse.authenticated && authResponse.authType === 'wallet') {
console.log('[BaseLayout] Кошелёк успешно подключен и аутентифицирован');
showSuccessMessage('Кошелёк успешно подключен!');
// Оповещаем компоненты об успешной авторизации
eventBus.emit('auth-state-changed', {
isAuthenticated: true,
authType: 'wallet',
fromBaseLayout: true
});
emit('auth-action-completed');
} else {
showErrorMessage('Не удалось завершить аутентификацию через кошелек.');
}
@@ -141,16 +146,10 @@ const disconnectWallet = async () => {
console.log('[BaseLayout] Выполняется выход из системы...');
try {
await api.post('/api/auth/logout');
await auth.checkAuth();
showSuccessMessage('Вы успешно вышли из системы');
removeFromStorage('guestMessages');
removeFromStorage('hasUserSentMessage');
// Оповещаем компоненты о выходе из системы
eventBus.emit('auth-state-changed', {
isAuthenticated: false,
fromBaseLayout: true
});
emit('auth-action-completed');
} catch (error) {
console.error('[BaseLayout] Ошибка при выходе из системы:', error);
showErrorMessage('Произошла ошибка при выходе из системы');
@@ -206,9 +205,31 @@ onMounted(() => {
}
/* Адаптивный дизайн */
@media (max-width: 1199px) {
.main-content {
max-width: calc(100% - 320px);
}
}
@media (max-width: 768px) {
.main-content {
max-width: 100%;
padding-bottom: 20px; /* Убираем большой отступ, так как панель теперь полноэкранная */
}
.main-content.no-right-sidebar {
padding-bottom: 20px;
}
}
@media (max-width: 480px) {
.main-content {
padding: 0 10px;
padding-bottom: 10px; /* Убираем большой отступ */
}
.main-content.no-right-sidebar {
padding-bottom: 10px;
}
}
</style>

View File

@@ -437,16 +437,14 @@ onUnmounted(() => {
</script>
<style scoped>
/* Стили здесь для инкапсуляции, можно вынести в home.css */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
margin: var(--spacing-lg) auto;
padding: 0; /* Убираю padding, так как он теперь задается через .main-content */
min-height: 500px; /* Или другая подходящая высота */
max-width: 1150px; /* Ограничиваем ширину чата */
width: 100%; /* Занимаем всю доступную ширину до максимума */
margin: var(--spacing-lg) 0;
padding: 0;
min-height: 500px;
width: 100%;
position: relative;
}
@@ -462,7 +460,7 @@ onUnmounted(() => {
top: 0;
left: 0;
right: 0;
bottom: calc(var(--chat-input-height, 80px) + 15px); /* Добавляем 15px отступа между сообщениями и полем ввода */
bottom: calc(var(--chat-input-height, 80px) + 15px);
transition: bottom var(--transition-normal);
}
@@ -482,16 +480,32 @@ onUnmounted(() => {
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
}
/* Стили для textarea, связанные с авто-ресайзом (дублируют home.css, но можно оставить для явности) */
.chat-input textarea {
width: 100%;
border: none;
background: transparent;
resize: none;
outline: none;
font-size: var(--font-size-md);
line-height: 1.5;
padding: var(--spacing-sm);
min-height: var(--chat-input-min-height, 40px);
max-height: var(--chat-input-max-height, 120px);
transition: all var(--transition-fast);
color: var(--color-dark);
overflow-y: hidden;
height: auto;
}
.chat-input textarea:focus {
outline: none;
}
.input-area {
display: flex;
align-items: flex-end;
gap: var(--spacing-sm);
width: 100%;
}
.chat-icons {
@@ -574,7 +588,7 @@ onUnmounted(() => {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-grey-light);
max-height: 100px; /* Можно увеличить, если нужно больше места */
max-height: 100px;
overflow-y: auto;
}
@@ -622,4 +636,57 @@ onUnmounted(() => {
line-height: 1;
padding: 0;
}
/* Добавляем адаптивные стили для мобильных устройств */
@media (max-width: 768px) {
.chat-container {
margin: var(--spacing-sm) auto;
}
.chat-messages {
padding: var(--spacing-md);
}
.chat-input {
padding: var(--spacing-xs) var(--spacing-sm);
}
.chat-icon-btn {
width: 32px;
height: 32px;
}
.chat-icon-btn svg {
width: 20px;
height: 20px;
}
}
@media (max-width: 480px) {
.chat-container {
margin: var(--spacing-xs) auto;
}
.chat-messages {
padding: var(--spacing-sm);
}
.input-area {
gap: 4px;
}
.chat-icon-btn {
width: 28px;
height: 28px;
}
.chat-icon-btn svg {
width: 18px;
height: 18px;
}
.preview-item {
font-size: var(--font-size-xs);
}
}
</style>

View File

@@ -65,7 +65,7 @@ onBeforeUnmount(() => {
<style scoped>
.header {
background-color: var(--color-white);
padding: 15px 20px;
padding: 15px 20px; /* Возвращаем горизонтальный padding */
position: sticky;
top: 0;
z-index: 100; /* Ensure header stays on top */
@@ -75,8 +75,7 @@ onBeforeUnmount(() => {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px; /* Optional: limit max width */
margin: 0 auto; /* Optional: center content */
/* Убираем max-width, margin, padding */
}
.header-text {
@@ -144,8 +143,10 @@ onBeforeUnmount(() => {
top: 6px;
}
/* Удаляем стили для трансформации бургера в крестик */
/*
.header-wallet-btn.active .hamburger-line {
background-color: transparent; /* Hide middle line */
background-color: transparent;
}
.header-wallet-btn.active .hamburger-line::before {
@@ -157,6 +158,7 @@ onBeforeUnmount(() => {
top: 0;
transform: rotate(-45deg);
}
*/
.nav-btn-text {
font-size: 0.9rem;

View File

@@ -171,7 +171,7 @@ const formatFileSize = (bytes) => {
</script>
<style scoped>
/* Стили можно скопировать из home.css или оставить глобальными */
/* Стили сообщений, полностью перенесенные из home.css */
.message {
margin-bottom: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
@@ -240,7 +240,7 @@ const formatFileSize = (bytes) => {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-xs); /* Добавлен отступ сверху */
margin-top: var(--spacing-xs);
}
.message-time {
@@ -272,7 +272,7 @@ const formatFileSize = (bytes) => {
border: 1px solid var(--color-danger);
}
/* --- НОВЫЕ СТИЛИ --- */
/* Стили для вложений */
.message-attachments {
margin-top: var(--spacing-sm);
border-top: 1px solid rgba(0, 0, 0, 0.1);
@@ -281,19 +281,19 @@ const formatFileSize = (bytes) => {
.attachment-item {
display: flex;
flex-direction: column; /* Отображаем элементы в столбец */
align-items: flex-start; /* Выравниваем по левому краю */
flex-direction: column;
align-items: flex-start;
}
.attachment-preview {
max-width: 100%;
max-height: 300px; /* Ограничение высоты для превью */
max-height: 300px;
margin-bottom: var(--spacing-xs);
border-radius: var(--radius-md);
}
.image-preview {
object-fit: cover; /* Сохраняем пропорции */
object-fit: cover;
}
.audio-preview {
@@ -301,7 +301,7 @@ const formatFileSize = (bytes) => {
}
.video-preview {
/* Стили для видео по умолчанию */
width: 100%;
}
.file-preview {
@@ -317,16 +317,47 @@ const formatFileSize = (bytes) => {
.attachment-name {
font-weight: 500;
margin-right: var(--spacing-xs);
color: var(--color-primary); /* Делаем имя файла похожим на ссылку */
color: var(--color-primary);
text-decoration: none;
}
.attachment-name:hover {
text-decoration: underline;
}
.attachment-size {
color: var(--color-grey);
font-size: var(--font-size-xs); /* Уменьшим размер */
font-size: var(--font-size-xs);
}
/* Адаптивные стили для разных экранов */
@media (max-width: 768px) {
.message {
max-width: 85%;
padding: var(--spacing-xs) var(--spacing-sm);
}
.ai-message {
max-width: 80%;
}
}
@media (max-width: 480px) {
.message {
max-width: 95%;
font-size: var(--font-size-sm);
}
.ai-message {
max-width: 90%;
}
.message-time {
font-size: calc(var(--font-size-xs) - 1px);
}
.attachment-preview {
max-height: 200px;
}
}
/* --- КОНЕЦ НОВЫХ СТИЛЕЙ --- */
</style>

View File

@@ -33,25 +33,69 @@
<!-- Навигационные кнопки -->
<div class="navigation-buttons">
<router-link to="/" class="nav-link-btn" active-class="active">
<i class="nav-icon">💬</i>
<span>Чат</span>
</router-link>
<router-link to="/crm" class="nav-link-btn" active-class="active">
<i class="nav-icon">👥</i>
<span>CRM</span>
</router-link>
<router-link to="/settings" class="nav-link-btn" active-class="active">
<i class="nav-icon"></i>
<span>Настройки</span>
</router-link>
</div>
<!-- Блок информации о пользователе -->
<div v-if="isAuthenticated" class="user-info-section sidebar-section">
<h3>Ваши идентификаторы:</h3>
<div class="user-info-item">
<span class="user-info-label">Кошелек:</span>
<span v-if="hasIdentityType('wallet')" class="user-info-value">
{{ truncateAddress(getIdentityValue('wallet')) }}
</span>
<span v-else class="user-info-value">Не подключен</span>
</div>
<!-- Можно добавить другие идентификаторы по аналогии -->
</div>
<!-- Блок баланса токенов -->
<div v-if="isAuthenticated" class="token-balances-section sidebar-section">
<h3>Баланс токенов:</h3>
<div v-if="isLoadingTokens" class="token-loading">
Загрузка балансов...
</div>
<div v-else-if="!tokenBalances || Object.keys(tokenBalances).length === 0" class="token-no-data">
Баланс не доступен
</div>
<div v-else>
<div v-if="tokenBalances.eth" class="token-balance">
<span class="token-name">ETH:</span>
<span class="token-amount">{{ Number(tokenBalances.eth).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.eth.symbol }}</span>
</div>
<div v-if="tokenBalances.bsc" class="token-balance">
<span class="token-name">BSC:</span>
<span class="token-amount">{{ Number(tokenBalances.bsc).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.bsc.symbol }}</span>
</div>
<div v-if="tokenBalances.arbitrum" class="token-balance">
<span class="token-name">ARB:</span>
<span class="token-amount">{{ Number(tokenBalances.arbitrum).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.arbitrum.symbol }}</span>
</div>
<div v-if="tokenBalances.polygon" class="token-balance">
<span class="token-name">POL:</span>
<span class="token-amount">{{ Number(tokenBalances.polygon).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.polygon.symbol }}</span>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { defineProps, defineEmits, ref, onMounted, onBeforeUnmount } from 'vue';
import { defineProps, defineEmits, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { TOKEN_CONTRACTS } from '../services/tokens';
import { useRouter } from 'vue-router';
import eventBus from '../utils/eventBus';
@@ -63,7 +107,8 @@ const props = defineProps({
telegramAuth: Object,
emailAuth: Object,
tokenBalances: Object,
identities: Array
identities: Array,
isLoadingTokens: Boolean
});
const emit = defineEmits(['update:modelValue', 'wallet-auth', 'disconnect-wallet']);
@@ -117,9 +162,64 @@ const getIdentityValue = (type) => {
const identity = props.identities.find((identity) => identity.provider === type);
return identity ? identity.provider_id : null;
};
// Добавляем watch для отслеживания props
watch(() => props.tokenBalances, (newVal, oldVal) => {
console.log('[Sidebar] tokenBalances prop changed:', JSON.stringify(newVal));
}, { deep: true });
watch(() => props.isLoadingTokens, (newVal, oldVal) => {
console.log(`[Sidebar] isLoadingTokens prop changed: ${newVal}`);
});
</script>
<style scoped>
.wallet-sidebar {
position: fixed;
top: 0;
right: 0;
width: 100%;
height: 100%;
background-color: var(--color-white);
z-index: 1000;
overflow-y: auto;
padding: var(--spacing-lg);
box-sizing: border-box;
display: flex;
flex-direction: column;
transition: transform var(--transition-normal), opacity var(--transition-normal);
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
}
.wallet-sidebar-content {
max-width: 600px;
width: 100%;
margin: 0 auto;
padding: 0 var(--spacing-md);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
/* Анимация появления и исчезновения правой панели */
.sidebar-slide-enter-active,
.sidebar-slide-leave-active {
transition: all var(--transition-normal);
}
.sidebar-slide-enter-from,
.sidebar-slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
.sidebar-slide-enter-to,
.sidebar-slide-leave-from {
transform: translateX(0);
opacity: 1;
}
.button-with-close {
display: flex;
align-items: center;
@@ -167,15 +267,17 @@ const getIdentityValue = (type) => {
.nav-link-btn {
display: flex;
align-items: center;
height: 48px;
background-color: var(--color-light);
color: var(--color-dark);
border: 1px solid var(--color-grey-light);
border-radius: var(--radius-lg);
padding: 12px 15px;
padding: 0 15px;
font-size: var(--font-size-md);
text-decoration: none;
transition: all var(--transition-normal);
cursor: pointer;
box-sizing: border-box;
}
.nav-link-btn.active {
@@ -188,9 +290,109 @@ const getIdentityValue = (type) => {
background-color: var(--color-grey-light);
}
.nav-icon {
margin-right: 10px;
font-size: 1.2em;
/* Стили для общих кнопок аутентификации/действий в сайдбаре */
.auth-btn {
width: 100%;
height: 48px;
border-radius: var(--radius-lg);
background-color: var(--color-light);
border: 1px solid rgba(0, 0, 0, 0.1);
color: var(--color-dark);
font-size: var(--font-size-md);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 var(--spacing-md);
box-sizing: border-box;
transition: all var(--transition-normal);
margin: 0;
text-decoration: none;
}
.auth-btn:hover {
background-color: var(--color-grey-light);
}
/* Новые стили для секций в сайдбаре */
.sidebar-section {
background-color: var(--color-light);
padding: var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
}
h3 {
color: var(--color-primary);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-md);
}
.token-balance,
.user-info-item {
display: flex;
align-items: center;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.token-name,
.user-info-label {
font-weight: bold;
width: 80px;
}
.token-amount {
flex: 1;
}
.token-symbol {
color: var(--color-text-light);
margin-left: var(--spacing-xs);
}
.token-no-data,
.user-info-empty {
color: var(--color-text-light);
font-style: italic;
font-size: var(--font-size-sm);
}
/* Добавляем стиль для индикатора загрузки */
.token-loading {
color: var(--color-text-light);
font-style: italic;
font-size: var(--font-size-sm);
}
/* Медиа-запросы для адаптивности */
@media screen and (min-width: 1200px) {
.wallet-sidebar {
width: 30%;
max-width: 350px;
}
}
@media screen and (min-width: 769px) and (max-width: 1199px) {
.wallet-sidebar {
width: 40%;
max-width: 320px;
}
}
@media screen and (max-width: 768px) {
/* На мобильных устройствах сайдбар по умолчанию занимает весь экран (width: 100%, height: 100%) */
/* Поэтому дополнительные правила для переопределения положения/размера не нужны */
/* Оставляем только adjustment for padding when needed */
.wallet-sidebar {
padding: var(--spacing-md);
/* Убраны bottom, top, height, max-height, чтобы вернуться к full-screen поведению */
}
.wallet-sidebar-content {
padding: 0;
gap: var(--spacing-md);
}
}
@media screen and (max-width: 480px) {
@@ -201,8 +403,14 @@ const getIdentityValue = (type) => {
font-size: 20px;
}
.auth-btn {
height: 42px;
font-size: var(--font-size-sm);
}
.nav-link-btn {
padding: 10px 12px;
height: 42px;
padding: 0 12px;
font-size: var(--font-size-sm);
}
}
@@ -215,8 +423,13 @@ const getIdentityValue = (type) => {
font-size: 18px;
}
.auth-btn {
height: 36px;
}
.nav-link-btn {
padding: 8px 10px;
height: 36px;
padding: 0 10px;
}
}
</style>

View File

@@ -1,5 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
// Импортируем (пока не созданные) компоненты для подстраниц настроек
const SettingsAiView = () => import('../views/settings/AiSettingsView.vue');
const SettingsBlockchainView = () => import('../views/settings/BlockchainSettingsView.vue');
const SettingsSecurityView = () => import('../views/settings/SecuritySettingsView.vue');
const SettingsInterfaceView = () => import('../views/settings/InterfaceSettingsView.vue');
import axios from 'axios';
console.log('router/index.js: Script loaded');
@@ -19,6 +24,35 @@ const routes = [
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
// Добавляем дочерние маршруты
children: [
{
path: 'ai',
name: 'settings-ai',
component: SettingsAiView,
},
{
path: 'blockchain',
name: 'settings-blockchain',
component: SettingsBlockchainView,
},
{
path: 'security',
name: 'settings-security',
component: SettingsSecurityView,
},
{
path: 'interface',
name: 'settings-interface',
component: SettingsInterfaceView,
},
// Опционально: перенаправление со /settings на первую подстраницу
{
path: '',
name: 'settings-index',
redirect: { name: 'settings-ai' }
}
]
},
];

View File

@@ -1,5 +1,11 @@
<template>
<BaseLayout>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div class="crm-view-container">
<h1>CRM Система</h1>
<div v-if="isLoading">Загрузка данных пользователя...</div>
@@ -29,13 +35,24 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits } from 'vue';
import { useAuth } from '../composables/useAuth';
import { useRouter } from 'vue-router';
import { setToStorage } from '../utils/storage';
import BaseLayout from '../components/BaseLayout.vue';
import eventBus from '../utils/eventBus';
// Определяем props
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
// Определяем emits
const emit = defineEmits(['auth-action-completed']);
const auth = useAuth();
const router = useRouter();
const isLoading = ref(true);
@@ -74,12 +91,12 @@ onBeforeUnmount(() => {
<style scoped>
.crm-view-container {
max-width: 1150px;
margin: 20px auto;
padding: 20px;
background-color: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-top: 20px;
margin-bottom: 20px;
}
h1 {

View File

@@ -1,5 +1,11 @@
<template>
<BaseLayout>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<ChatInterface
:messages="messages"
:is-loading="isLoading || isConnectingWallet"
@@ -13,17 +19,27 @@
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount } from 'vue';
import { ref, onMounted, watch, onBeforeUnmount, defineProps, defineEmits } from 'vue';
import { useAuth } from '../composables/useAuth';
import { useChat } from '../composables/useChat';
import { connectWithWallet } from '../services/wallet';
import eventBus from '../utils/eventBus';
import '../assets/styles/home.css';
import BaseLayout from '../components/BaseLayout.vue';
import ChatInterface from '../components/ChatInterface.vue';
console.log('HomeView.vue: Using BaseLayout');
// Определяем props, переданные из App.vue через RouterView
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
// Определяем emits
const emit = defineEmits(['auth-action-completed']);
// =====================================================================
// 1. ИСПОЛЬЗОВАНИЕ COMPOSABLES
// =====================================================================

View File

@@ -1,192 +1,64 @@
<template>
<BaseLayout>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div class="settings-view-container">
<h1>Настройки</h1>
<!-- Блок информации о пользователе (всегда виден) -->
<div class="user-info-section">
<h3>Ваши идентификаторы:</h3>
<div v-if="!auth.isAuthenticated.value" class="user-info-empty">
Войдите, чтобы увидеть идентификаторы
</div>
<div v-else class="user-info-item">
<span class="user-info-label">Кошелек:</span>
<span v-if="hasIdentityType('wallet')" class="user-info-value">
{{ truncateAddress(getIdentityValue('wallet')) }}
</span>
<span v-else class="user-info-value">Не подключен</span>
</div>
</div>
<!-- Блок баланса токенов (всегда виден) -->
<div class="token-balances-section">
<h3>Баланс токенов:</h3>
<div v-if="!auth.isAuthenticated.value" class="token-no-data">
Войдите, чтобы увидеть баланс токенов
</div>
<div v-else-if="isLoadingTokens" class="token-loading">
Загрузка балансов...
</div>
<div v-else-if="!hasTokenBalances" class="token-no-data">
Информация о балансе не доступна
</div>
<div v-else>
<div v-if="tokenBalances.eth" class="token-balance">
<span class="token-name">ETH:</span>
<span class="token-amount">{{ Number(tokenBalances.eth).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.eth.symbol }}</span>
</div>
<div v-if="tokenBalances.bsc" class="token-balance">
<span class="token-name">BSC:</span>
<span class="token-amount">{{ Number(tokenBalances.bsc).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.bsc.symbol }}</span>
</div>
<div v-if="tokenBalances.arbitrum" class="token-balance">
<span class="token-name">ARB:</span>
<span class="token-amount">{{ Number(tokenBalances.arbitrum).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.arbitrum.symbol }}</span>
</div>
<div v-if="tokenBalances.polygon" class="token-balance">
<span class="token-name">POL:</span>
<span class="token-amount">{{ Number(tokenBalances.polygon).toLocaleString() }}</span>
<span class="token-symbol">{{ TOKEN_CONTRACTS.polygon.symbol }}</span>
</div>
</div>
</div>
<div v-if="isLoading">Загрузка данных пользователя...</div>
<div v-else-if="!auth.isAuthenticated.value">
<p>Для доступа к настройкам необходимо <button @click="goToHomeAndShowSidebar">войти</button>.</p>
</div>
<div v-else>
<p>Добро пожаловать в Настройки!</p>
<div v-if="auth.isAdmin.value">
<p><strong>У вас полный доступ (Администратор).</strong></p>
<!-- Сюда будут добавляться полные настройки -->
<p>Здесь будут настройки системы, управление пользователями и т.д.</p>
</div>
<div v-else>
<p><strong>У вас ограниченный доступ.</strong></p>
<!-- Сюда будут добавляться ограниченные настройки -->
<p>Здесь будут настройки профиля, уведомлений и т.д.</p>
</div>
<!-- Общие настройки для всех авторизованных -->
<div class="general-settings">
<h3>Общие настройки</h3>
<label>
Язык интерфейса:
<select v-model="selectedLanguage">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</label>
<div v-else class="settings-navigation-buttons">
<h3>Разделы настроек:</h3>
<div class="buttons-grid">
<router-link :to="{ name: 'settings-ai' }" class="btn btn-secondary">ИИ</router-link>
<router-link :to="{ name: 'settings-blockchain' }" class="btn btn-secondary">Блокчейн</router-link>
<router-link :to="{ name: 'settings-security' }" class="btn btn-secondary">Безопасность</router-link>
<router-link :to="{ name: 'settings-interface' }" class="btn btn-secondary">Интерфейс</router-link>
</div>
</div>
<!-- Router view для отображения дочерних компонентов настроек -->
<router-view></router-view>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount, computed } from 'vue';
import { ref, onMounted, watch, onBeforeUnmount, computed, defineProps, defineEmits } from 'vue';
import { useAuth } from '../composables/useAuth';
import { useRouter } from 'vue-router';
import { getFromStorage, setToStorage } from '../utils/storage';
import BaseLayout from '../components/BaseLayout.vue';
import eventBus from '../utils/eventBus';
import { TOKEN_CONTRACTS } from '../services/tokens';
import { fetchTokenBalances } from '../services/tokens';
// Определяем props
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
// Определяем emits
const emit = defineEmits(['auth-action-completed']);
const auth = useAuth();
const router = useRouter();
const isLoading = ref(true);
const selectedLanguage = ref(getFromStorage('userLanguage', 'ru'));
// Состояние для токенов
const tokenBalances = ref({});
const isLoadingTokens = ref(false);
const hasTokenBalances = computed(() => {
return tokenBalances.value &&
Object.keys(tokenBalances.value).length > 0 &&
Object.values(tokenBalances.value).some(value => parseInt(value) > 0);
});
// Получаем данные об идентификаторах пользователя
const identities = computed(() => auth.identities.value);
// Вспомогательные функции
const truncateAddress = (address) => {
if (!address) return '';
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
};
const hasIdentityType = (type) => {
if (!identities.value) return false;
return identities.value.some((identity) => identity.provider === type);
};
const getIdentityValue = (type) => {
if (!identities.value) return null;
const identity = identities.value.find((identity) => identity.provider === type);
return identity ? identity.provider_id : null;
};
// Обновление балансов токенов
const refreshTokenBalances = async () => {
if (!hasIdentityType('wallet')) return;
isLoadingTokens.value = true;
try {
const walletAddress = getIdentityValue('wallet');
console.log('[SettingsView] Обновление балансов для адреса:', walletAddress);
const balances = await fetchTokenBalances(walletAddress);
console.log('[SettingsView] Полученные балансы:', balances);
tokenBalances.value = balances || {};
} catch (error) {
console.error('[SettingsView] Ошибка при получении балансов:', error);
tokenBalances.value = {};
} finally {
isLoadingTokens.value = false;
}
};
// Следим за изменениями в идентификаторах
watch(() => identities.value, (newIdentities, oldIdentities) => {
if (hasIdentityType('wallet')) {
// Проверяем, появился ли новый идентификатор кошелька или изменился существующий
const newWalletId = getIdentityValue('wallet');
const oldWalletIdentity = oldIdentities ? oldIdentities.find(id => id.provider === 'wallet') : null;
const oldWalletId = oldWalletIdentity ? oldWalletIdentity.provider_id : null;
if (newWalletId !== oldWalletId) {
console.log('[SettingsView] Обнаружено изменение идентификатора кошелька, обновляем балансы');
refreshTokenBalances();
}
}
}, { deep: true });
// Обработчик события изменения авторизации
const handleAuthEvent = (eventData) => {
console.log('[SettingsView] Получено событие изменения авторизации:', eventData);
isLoading.value = false;
// При изменении авторизации обновляем балансы
if (eventData.isAuthenticated) {
setTimeout(() => {
refreshTokenBalances(); // Небольшая задержка для обновления идентификаторов
}, 500);
}
};
// Сохраняем язык при изменении
watch(selectedLanguage, (newLang) => {
setToStorage('userLanguage', newLang);
// TODO: Добавить логику для реальной смены языка интерфейса (например, через i18n)
console.log(`[Settings] Язык изменен на: ${newLang}`);
});
const goToHomeAndShowSidebar = () => {
setToStorage('showWalletSidebar', true);
router.push({ name: 'home' });
@@ -201,11 +73,6 @@ onMounted(() => {
// Подписка на события авторизации
unsubscribe = eventBus.on('auth-state-changed', handleAuthEvent);
// Обновляем данные о балансе токенов при загрузке
if (auth.isAuthenticated.value && hasIdentityType('wallet')) {
refreshTokenBalances();
}
});
onBeforeUnmount(() => {
@@ -214,161 +81,90 @@ onBeforeUnmount(() => {
unsubscribe();
}
});
// Следим за состоянием авторизации для очистки данных при выходе
watch(() => auth.isAuthenticated.value, (isAuth) => {
if (!isAuth) {
// Очищаем данные при выходе из системы
tokenBalances.value = {};
}
});
</script>
<style scoped>
/* Основной контейнер */
.settings-view-container {
max-width: 1150px;
margin: 20px auto;
padding: 20px;
padding: var(--block-padding);
background-color: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: var(--color-dark);
border-radius: var(--block-radius);
box-shadow: var(--shadow-md);
margin-top: 20px;
margin-bottom: 20px;
}
/* Заголовки */
h1 {
color: var(--color-dark);
margin-bottom: var(--spacing-lg);
}
h3 {
color: var(--color-primary);
margin-bottom: var(--spacing-md);
}
/* Общие элементы */
p {
line-height: 1.6;
margin-bottom: 10px;
margin-bottom: var(--spacing-sm);
}
strong {
color: var(--color-primary);
}
.general-settings {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--color-grey-light);
}
label {
display: block;
margin-bottom: 10px;
}
select {
padding: 8px;
border: 1px solid var(--color-grey);
border-radius: var(--radius-md);
margin-left: 10px;
}
button {
padding: 5px 10px;
background-color: var(--color-primary);
/* Стили для кнопки "войти" */
.settings-view-container > div > p > button {
background-color: var(--color-accent);
color: var(--color-white);
border: none;
border-radius: var(--radius-md);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
cursor: pointer;
margin-left: 5px;
}
button:hover {
opacity: 0.9;
}
button:disabled {
background-color: var(--color-grey);
cursor: not-allowed;
opacity: 0.7;
transition: background-color var(--transition-fast);
margin-left: var(--spacing-xs);
}
/* Стили для блока информации о пользователе */
.user-info-section {
margin-top: 20px;
margin-bottom: 20px;
padding: 15px;
background-color: var(--color-light);
border-radius: var(--radius-md);
border: 1px solid var(--color-grey-light);
.settings-view-container > div > p > button:hover {
background-color: var(--color-accent-dark);
}
.user-info-item {
margin-top: 10px;
display: flex;
align-items: center;
/* Новые стили для кнопок навигации */
.settings-navigation-buttons {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--color-grey-light);
}
.user-info-label {
min-width: 100px;
font-weight: 500;
.buttons-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.user-info-value {
font-family: monospace;
padding: 5px 10px;
background-color: var(--color-white);
border-radius: var(--radius-sm);
border: 1px solid var(--color-grey-light);
}
/* Стили для блока баланса токенов */
.token-balances-section {
margin-top: 20px;
margin-bottom: 20px;
padding: 15px;
background-color: var(--color-light);
border-radius: var(--radius-md);
border: 1px solid var(--color-grey-light);
}
.token-balance {
display: flex;
align-items: center;
margin-top: 10px;
}
.token-name {
min-width: 60px;
font-weight: 500;
}
.token-amount {
font-family: monospace;
padding: 5px 10px;
background-color: var(--color-white);
border-radius: var(--radius-sm);
border: 1px solid var(--color-grey-light);
margin-right: 10px;
}
.token-symbol {
color: var(--color-primary);
font-weight: 500;
}
.token-loading,
.token-no-data {
margin: 15px 0;
padding: 10px;
.buttons-grid .btn {
width: 100%;
text-align: center;
color: var(--color-grey-dark);
font-style: italic;
justify-content: center;
}
/* Удаляем стили для кнопки обновления баланса, поскольку больше не нужны */
.token-action,
.refresh-btn {
display: none;
/* Анимации */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.user-info-empty,
.token-no-data {
margin: 15px 0;
padding: 10px;
text-align: center;
color: var(--color-grey-dark);
font-style: italic;
/* Адаптивный дизайн */
@media (max-width: 768px) {
.settings-navigation-buttons {
grid-template-columns: 1fr;
}
.buttons-grid {
grid-column: 1;
grid-row: auto;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<div class="ai-settings settings-panel">
<h2>Настройки ИИ</h2>
<!-- Панель Промт -->
<div class="sub-settings-panel">
<h3>Настройки промптов</h3>
<div class="setting-form">
<p>Здесь будут настройки для конфигурации промптов</p>
<textarea v-model="settings.prompt" placeholder="Базовый промпт для ИИ..." rows="5" class="form-control"></textarea>
<button class="btn btn-primary" @click="saveSettings('prompt')">Сохранить</button>
</div>
</div>
<!-- Панель RAG -->
<div class="sub-settings-panel">
<h3>Настройки RAG (Retrieval Augmented Generation)</h3>
<div class="setting-form">
<p>Конфигурация системы поиска и генерации ответов</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" v-model="settings.ragEnabled">
Включить RAG
</label>
</div>
<button class="btn btn-primary" @click="saveSettings('rag')">Сохранить</button>
</div>
</div>
<!-- Панель Каналы -->
<div class="sub-settings-panel">
<h3>Настройки каналов для ИИ</h3>
<div class="setting-form">
<p>Управление каналами связи для ИИ</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" v-model="settings.channels.telegram">
Telegram
</label>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" v-model="settings.channels.email">
Email
</label>
</div>
<button class="btn btn-primary" @click="saveSettings('channels')">Сохранить</button>
</div>
</div>
<!-- Панель Модели -->
<div class="sub-settings-panel">
<h3>Настройки моделей ИИ</h3>
<div class="setting-form">
<p>Выбор и конфигурация моделей ИИ</p>
<div class="form-group">
<label class="form-label">Модель по умолчанию:</label>
<select v-model="settings.defaultModel" class="form-control">
<option value="claude-3-haiku">Claude 3 Haiku</option>
<option value="claude-3-sonnet">Claude 3 Sonnet</option>
<option value="claude-3-opus">Claude 3 Opus</option>
<option value="gpt-4o">GPT-4o</option>
</select>
</div>
<button class="btn btn-primary" @click="saveSettings('models')">Сохранить</button>
</div>
</div>
</div>
</template>
<script setup>
// Логика из AISettings.vue
import { reactive, onMounted } from 'vue';
// TODO: Импортировать API для загрузки/сохранения
// Локальное состояние настроек
const settings = reactive({
prompt: '',
ragEnabled: false,
channels: {
telegram: false,
email: false
},
defaultModel: 'claude-3-sonnet'
});
// Загрузка настроек при монтировании
onMounted(() => {
loadAiSettings();
});
const loadAiSettings = async () => {
console.log('[AiSettingsView] Загрузка настроек ИИ...');
// TODO: Заменить на реальный вызов API
// Пример:
// try {
// const response = await api.get('/api/settings/ai');
// Object.assign(settings, response.data);
// } catch (error) {
// console.error('Ошибка загрузки настроек ИИ:', error);
// }
};
// Сохранение настроек
const saveSettings = async (section) => {
console.log(`[AiSettingsView] Сохранение настроек раздела: ${section}`);
// TODO: Заменить на реальный вызов API
// Пример:
// try {
// const dataToSave = { [section]: settings[section] }; // Или вся группа настроек
// await api.post('/api/settings/ai', dataToSave);
// // Показать сообщение об успехе
// } catch (error) {
// console.error('Ошибка сохранения настроек ИИ:', error);
// // Показать сообщение об ошибке
// }
};
</script>
<style scoped>
.settings-panel {
padding: var(--block-padding);
background-color: var(--color-light);
border-radius: var(--radius-md);
margin-top: var(--spacing-lg);
animation: fadeIn var(--transition-normal);
}
h2 {
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-grey-light);
padding-bottom: var(--spacing-md);
}
h3 {
margin-bottom: var(--spacing-md);
color: var(--color-primary);
}
.sub-settings-panel {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px dashed var(--color-grey-light);
}
.sub-settings-panel:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.setting-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
margin-bottom: 0; /* Убираем лишний отступ, т.к. есть gap */
}
.form-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.form-control {
max-width: 500px; /* Ограничим ширину для select/textarea */
}
.btn-primary {
align-self: flex-start;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="blockchain-settings settings-panel">
<h2>Настройки Блокчейна</h2>
<!-- Панель Смарт-контракты -->
<div class="sub-settings-panel">
<h3>Настройки смарт-контрактов</h3>
<div class="setting-form">
<p>Управление смарт-контрактами</p>
<div class="form-group">
<label class="form-label">Адрес основного контракта:</label>
<input type="text" v-model="settings.contractAddress" class="form-control">
</div>
<button class="btn btn-primary" @click="saveSettings('smartContract')">Сохранить</button>
</div>
</div>
<!-- Панель Кворум -->
<div class="sub-settings-panel">
<h3>Настройки кворума</h3>
<div class="setting-form">
<p>Настройки кворума для блокчейн-операций</p>
<div class="form-group">
<label class="form-label">Минимальный кворум (%):</label>
<input type="number" v-model="settings.quorumPercent" min="0" max="100" class="form-control">
</div>
<button class="btn btn-primary" @click="saveSettings('quorum')">Сохранить</button>
</div>
</div>
<!-- Панель RWA -->
<div class="sub-settings-panel">
<h3>Настройки Real World Assets (RWA)</h3>
<div class="setting-form">
<p>Конфигурация для работы с реальными активами</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" v-model="settings.rwaEnabled">
Включить поддержку RWA
</label>
</div>
<button class="btn btn-primary" @click="saveSettings('rwa')">Сохранить</button>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted } from 'vue';
// TODO: Импортировать API
const settings = reactive({
contractAddress: '',
quorumPercent: 51,
rwaEnabled: false
});
onMounted(() => {
loadBlockchainSettings();
});
const loadBlockchainSettings = async () => {
console.log('[BlockchainSettingsView] Загрузка настроек блокчейна...');
// TODO: API call
};
const saveSettings = async (section) => {
console.log(`[BlockchainSettingsView] Сохранение настроек раздела: ${section}`);
// TODO: API call
};
</script>
<style scoped>
.settings-panel {
padding: var(--block-padding);
background-color: var(--color-light);
border-radius: var(--radius-md);
margin-top: var(--spacing-lg);
animation: fadeIn var(--transition-normal);
}
h2 {
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-grey-light);
padding-bottom: var(--spacing-md);
}
h3 {
margin-bottom: var(--spacing-md);
color: var(--color-primary);
}
.sub-settings-panel {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px dashed var(--color-grey-light);
}
.sub-settings-panel:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.setting-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.form-control {
max-width: 500px;
}
.btn-primary {
align-self: flex-start;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="interface-settings settings-panel">
<h2>Настройки Интерфейса</h2>
<!-- Панель Язык -->
<div class="sub-settings-panel">
<h3>Настройки языка</h3>
<div class="setting-form">
<p>Выбор языка интерфейса</p>
<div class="form-group">
<label class="form-label">Язык интерфейса:</label>
<select v-model="selectedLanguage" class="form-control">
<option value="ru">Русский</option>
<option value="en">English</option>
<!-- Добавить другие языки по необходимости -->
</select>
</div>
<button class="btn btn-primary" @click="saveLanguageSetting">Сохранить язык</button>
</div>
</div>
<!-- Другие настройки интерфейса можно добавить сюда -->
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { getFromStorage, setToStorage } from '../../utils/storage'; // Путь к utils может отличаться
// TODO: Импортировать API для сохранения, если нужно
const selectedLanguage = ref(getFromStorage('userLanguage', 'ru'));
// Функция сохранения
const saveLanguageSetting = () => {
setToStorage('userLanguage', selectedLanguage.value);
console.log(`[InterfaceSettingsView] Язык сохранен как: ${selectedLanguage.value}`);
// TODO: Добавить реальную смену языка (i18n)
// TODO: Возможно, отправить на сервер, если язык влияет на бэкенд
// alert('Язык сохранен!'); // Пример уведомления
};
// Можно убрать watch, если сохранение происходит только по кнопке
// watch(selectedLanguage, (newLang) => {
// setToStorage('userLanguage', newLang);
// console.log(`[InterfaceSettingsView] Язык изменен на: ${newLang}`);
// });
</script>
<style scoped>
.settings-panel {
padding: var(--block-padding);
background-color: var(--color-light);
border-radius: var(--radius-md);
margin-top: var(--spacing-lg);
animation: fadeIn var(--transition-normal);
}
h2 {
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-grey-light);
padding-bottom: var(--spacing-md);
}
h3 {
margin-bottom: var(--spacing-md);
color: var(--color-primary);
}
.sub-settings-panel {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
/* Убираем нижнюю границу, если это последняя панель */
}
.setting-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
margin-bottom: 0;
}
.form-label {
display: block; /* Можно оставить блочным */
margin-bottom: var(--spacing-xs);
}
.form-control {
max-width: 300px; /* Ограничим ширину select */
}
.btn-primary {
align-self: flex-start;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="security-settings settings-panel">
<h2>Настройки Безопасности</h2>
<!-- Панель Авторизация -->
<div class="sub-settings-panel">
<h3>Настройки авторизации</h3>
<div class="setting-form">
<p>Управление параметрами авторизации</p>
<div class="form-group">
<label class="form-label">Метод авторизации по умолчанию:</label>
<select v-model="settings.defaultAuthMethod" class="form-control">
<option value="wallet">Кошелек</option>
<option value="email">Email</option>
<option value="telegram">Telegram</option>
</select>
</div>
<button class="btn btn-primary" @click="saveSettings('authorization')">Сохранить</button>
</div>
</div>
<!-- Панель RPC -->
<div class="sub-settings-panel">
<h3>Настройки RPC</h3>
<div class="setting-form">
<p>Настройка RPC-эндпоинтов</p>
<div class="form-group">
<label class="form-label">Ethereum RPC:</label>
<input type="text" v-model="settings.rpcEndpoints.ethereum" class="form-control">
</div>
<div class="form-group">
<label class="form-label">Polygon RPC:</label>
<input type="text" v-model="settings.rpcEndpoints.polygon" class="form-control">
</div>
<button class="btn btn-primary" @click="saveSettings('rpc')">Сохранить</button>
</div>
</div>
<!-- Панель Noda -->
<div class="sub-settings-panel">
<h3>Настройки Noda</h3>
<div class="setting-form">
<p>Конфигурация нод</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" v-model="settings.useCustomNode">
Использовать собственную ноду
</label>
</div>
<div class="form-group" v-if="settings.useCustomNode">
<label class="form-label">URL собственной ноды:</label>
<input type="text" v-model="settings.customNodeUrl" class="form-control">
</div>
<button class="btn btn-primary" @click="saveSettings('noda')">Сохранить</button>
</div>
</div>
<!-- Панель Хранилище -->
<div class="sub-settings-panel">
<h3>Настройки хранилища</h3>
<div class="setting-form">
<p>Настройки для хранения данных</p>
<div class="form-group">
<label class="form-label">Тип хранилища:</label>
<select v-model="settings.storageType" class="form-control">
<option value="local">Локальное</option>
<option value="ipfs">IPFS</option>
<option value="arweave">Arweave</option>
</select>
</div>
<button class="btn btn-primary" @click="saveSettings('storage')">Сохранить</button>
</div>
</div>
<!-- Панель CPU -->
<div class="sub-settings-panel">
<h3>Настройки CPU</h3>
<div class="setting-form">
<p>Оптимизация использования CPU</p>
<div class="form-group">
<label class="form-label">Максимальное использование CPU (%):</label>
<input type="number" v-model="settings.maxCpuUsage" min="0" max="100" class="form-control">
</div>
<button class="btn btn-primary" @click="saveSettings('cpu')">Сохранить</button>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted } from 'vue';
// TODO: Импортировать API
const settings = reactive({
defaultAuthMethod: 'wallet',
rpcEndpoints: {
ethereum: '',
polygon: ''
},
useCustomNode: false,
customNodeUrl: '',
storageType: 'local',
maxCpuUsage: 80
});
onMounted(() => {
loadSecuritySettings();
});
const loadSecuritySettings = async () => {
console.log('[SecuritySettingsView] Загрузка настроек безопасности...');
// TODO: API call
};
const saveSettings = async (section) => {
console.log(`[SecuritySettingsView] Сохранение настроек раздела: ${section}`);
// TODO: API call
};
</script>
<style scoped>
.settings-panel {
padding: var(--block-padding);
background-color: var(--color-light);
border-radius: var(--radius-md);
margin-top: var(--spacing-lg);
animation: fadeIn var(--transition-normal);
}
h2 {
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-grey-light);
padding-bottom: var(--spacing-md);
}
h3 {
margin-bottom: var(--spacing-md);
color: var(--color-primary);
}
.sub-settings-panel {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px dashed var(--color-grey-light);
}
.sub-settings-panel:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.setting-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.form-control {
max-width: 500px;
}
.btn-primary {
align-self: flex-start;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>