Описание изменений

This commit is contained in:
2025-03-03 20:44:10 +03:00
parent d36fc255d8
commit 97ca5e4b64
43 changed files with 7277 additions and 2630 deletions

View File

@@ -1,4 +0,0 @@
{
"typescript.validate.enable": false,
"javascript.validate.enable": true
}

View File

@@ -15,6 +15,8 @@
color: #2c3e50; color: #2c3e50;
} }
</style> </style>
<!-- Добавляем Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

2064
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -9,12 +9,20 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"ethers": "^6.11.1", "axios": "^1.3.4",
"vue": "^3.4.15", "buffer": "^6.0.3",
"vue-router": "^4.2.5" "connect-pg-simple": "^10.0.0",
"ethers": "6.13.5",
"pinia": "^2.0.33",
"siwe": "^2.1.4",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^4.1.0",
"vite": "^5.0.11" "axios-mock-adapter": "^2.1.0",
"rollup": "^3.29.4",
"rollup-plugin-polyfill-node": "^0.12.0",
"vite": "^4.2.1"
} }
} }

View File

View File

@@ -1,149 +1,286 @@
<template> <template>
<div id="app"> <div id="app">
<header class="app-header">
<div class="header-brand">
<h1>DApp for Business</h1>
</div>
<div class="header-auth">
<template v-if="auth.isAuthenticated">
<span class="user-address">{{ shortAddress }}</span>
<button class="btn btn-outline" @click="handleDisconnect">Отключить кошелек</button>
</template>
<template v-else>
<button class="btn btn-primary" @click="navigateToHome">Подключиться</button>
</template>
</div>
</header>
<div class="app-layout"> <div class="app-layout">
<Sidebar <!-- Сайдбар для авторизованных пользователей -->
v-if="isConnected && isAdmin" <aside v-if="auth.isAuthenticated" class="sidebar">
:isAdmin="isAdmin" <nav class="sidebar-nav">
@update:collapsed="isSidebarCollapsed = $event" <router-link to="/" class="nav-item">
/> <span class="nav-icon">🏠</span>
<span class="nav-text">Главная</span>
</router-link>
<router-link v-if="auth.isAdmin" to="/dashboard" class="nav-item">
<span class="nav-icon">📊</span>
<span class="nav-text">Дашборд</span>
</router-link>
<router-link to="/kanban" class="nav-item">
<span class="nav-icon">📋</span>
<span class="nav-text">Канбан</span>
</router-link>
<router-link v-if="auth.isAdmin" to="/access-test" class="nav-item">
<span class="nav-icon">🔐</span>
<span class="nav-text">Смарт-контракты</span>
</router-link>
</nav>
</aside>
<main class="main-content"> <main class="main-content">
<WalletConnection <div v-if="isLoading" class="loading">
:isConnected="isConnected" Загрузка...
:userAddress="userAddress" </div>
@connect="handleConnect" <router-view v-else />
@disconnect="handleDisconnect"
/>
<router-view
:userAddress="userAddress"
:isConnected="isConnected"
:isAdmin="isAdmin"
@chatUpdated="handleChatUpdate"
/>
</main> </main>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, computed, provide } from 'vue';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
import WalletConnection from './components/WalletConnection.vue' import { useAuthStore } from './stores/auth';
import Sidebar from './components/Sidebar/Sidebar.vue' import axios from 'axios';
import { connectWallet } from './services/wallet';
const router = useRouter() const router = useRouter();
const isConnected = ref(false) const auth = useAuthStore();
const userAddress = ref('') const isLoading = ref(true);
const isAdmin = ref(false)
const isSidebarCollapsed = ref(false)
// При отключении кошелька перенаправляем на главную // Вычисляемое свойство для отображения сокращенного адреса
watch(isConnected, (newValue) => { const shortAddress = computed(() => {
if (!newValue) { if (!auth.address) return '';
router.push('/') return `${auth.address.substring(0, 6)}...${auth.address.substring(auth.address.length - 4)}`;
} });
})
// Проверяем сессию при загрузке приложения
onMounted(async () => {
console.log('App mounted');
// Проверка сессии при загрузке
async function checkSession() {
try { try {
const response = await fetch('http://127.0.0.1:3000/session', { // Восстанавливаем состояние аутентификации из localStorage
credentials: 'include' auth.restoreAuth();
})
const data = await response.json()
if (data.authenticated) { // Проверяем сессию на сервере
userAddress.value = data.address const response = await axios.get('/api/auth/check');
isConnected.value = true console.log('Проверка сессии:', response.data);
await checkAdminStatus()
// Если сессия активна, но состояние аутентификации не установлено
if (response.data.authenticated && !auth.isAuthenticated) {
auth.setAuth({
address: response.data.address,
isAdmin: response.data.isAdmin,
authType: response.data.authType || 'wallet'
});
}
// Если сессия не активна, но состояние аутентификации установлено
if (!response.data.authenticated && auth.isAuthenticated) {
auth.disconnect();
} }
} catch (error) { } catch (error) {
console.error('Ошибка проверки сессии:', error) console.error('Error checking session:', error);
} // Не отключаем пользователя при ошибке проверки сессии
} } finally {
isLoading.value = false;
// Проверка прав админа
async function checkAdminStatus() {
try {
const response = await fetch('http://127.0.0.1:3000/api/admin/check', {
credentials: 'include'
})
if (response.ok) {
const { isAdmin: adminStatus } = await response.json()
isAdmin.value = adminStatus
console.log('Проверка прав после входа:', {
userAddress: userAddress.value,
isAdmin: isAdmin.value
})
}
} catch (error) {
console.error('Ошибка проверки прав админа:', error)
}
}
async function handleConnect(address) {
userAddress.value = address
isConnected.value = true
await checkAdminStatus()
} }
});
// Функция для отключения кошелька
async function handleDisconnect() { async function handleDisconnect() {
await auth.disconnect();
router.push('/');
}
// Функция для подключения кошелька
async function navigateToHome() {
console.log('Connecting wallet...');
try { try {
// Отправляем запрос на выход await connectWallet((errorMessage) => {
await fetch('http://127.0.0.1:3000/signout', { console.error('Ошибка при подключении кошелька:', errorMessage);
method: 'POST', // Можно добавить отображение ошибки пользователю
credentials: 'include' });
})
userAddress.value = ''
isConnected.value = false
isAdmin.value = false
} catch (error) { } catch (error) {
console.error('Ошибка при отключении:', error) console.error('Ошибка при подключении кошелька:', error);
// Если не удалось подключить кошелек, перенаправляем на главную страницу
console.log('Navigating to home page');
router.push('/');
// Добавляем небольшую задержку, чтобы убедиться, что компонент HomeView загрузился
setTimeout(() => {
// Прокручиваем страницу вниз, чтобы показать опции подключения
const chatMessages = document.querySelector('.chat-messages');
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Если опции подключения еще не отображаются, имитируем отправку сообщения
const authOptions = document.querySelector('.auth-options');
if (!authOptions) {
const sendButton = document.querySelector('.send-btn');
if (sendButton) {
// Заполняем поле ввода
const textarea = document.querySelector('textarea');
if (textarea) {
textarea.value = 'Привет';
}
// Нажимаем кнопку отправки
sendButton.click();
}
}
}, 500);
} }
} }
function handleChatUpdate() { // Предоставляем состояние аутентификации всем компонентам
if (isAdmin.value) { provide('auth', auth);
// Обновляем данные в админской панели
// dataTables.value?.fetchData()
}
}
onMounted(() => {
checkSession()
})
</script> </script>
<style> <style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
#app { #app {
height: 100vh; height: 100vh;
overflow: hidden; display: flex;
flex-direction: column;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #1976d2;
color: white;
padding: 0.75rem 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.header-brand h1 {
font-size: 1.5rem;
margin: 0;
}
.header-auth {
display: flex;
align-items: center;
gap: 1rem;
}
.user-address {
font-family: monospace;
background-color: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.btn {
background: none;
border: 1px solid white;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.btn-outline {
border: 1px solid white;
}
.btn-primary {
background-color: white;
color: #1976d2;
border: none;
} }
.app-layout { .app-layout {
display: flex; display: flex;
height: 100%; flex: 1;
overflow: hidden;
}
.sidebar {
width: 250px;
background-color: #fff;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
overflow-y: auto;
z-index: 50;
}
.sidebar-nav {
padding: 1rem 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: #333;
text-decoration: none;
transition: background-color 0.2s;
}
.nav-item:hover {
background-color: #f5f5f5;
}
.nav-item.router-link-active {
background-color: #e3f2fd;
color: #1976d2;
border-left: 3px solid #1976d2;
}
.nav-icon {
margin-right: 0.75rem;
font-size: 1.2rem;
} }
.main-content { .main-content {
flex: 1; flex: 1;
display: flex; padding: 1.5rem;
flex-direction: column;
padding: 1.5rem 2rem; /* Соответствует отступам header */
margin-left: v-bind("isConnected && isAdmin ? (isSidebarCollapsed ? '80px' : '270px') : '0'");
overflow-y: auto; overflow-y: auto;
transition: margin-left 0.3s ease; background-color: #f5f5f5;
margin-top: 70px; /* Высота header + верхний отступ */
} }
/* Когда сайдбар скрыт */ .loading {
.app-layout:not(:has(.sidebar)) .main-content { display: flex;
margin-left: 0; justify-content: center;
padding-left: 2rem; /* Сохраняем отступ слева когда сайдбар скрыт */ align-items: center;
} height: 100%;
font-size: 1.2rem;
/* Стили для заголовка */ color: #666;
h1 {
margin-top: 0;
} }
</style> </style>

View File

@@ -1,238 +0,0 @@
<template>
<div class="ai-assistant">
<h3>AI Ассистент</h3>
<div class="chat-container" ref="chatContainer">
<div v-if="messages.length === 0" class="empty-state">
Начните диалог с AI ассистентом
</div>
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.role]">
{{ message.content }}
</div>
</div>
<div class="input-container">
<input
v-model="userInput"
@keyup.enter="sendMessage"
placeholder="Введите сообщение..."
:disabled="isLoading"
/>
<button
@click="sendMessage"
:disabled="isLoading || !userInput"
>
{{ isLoading ? 'Отправка...' : 'Отправить' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { getAddress } from 'ethers'
const props = defineProps({
userAddress: String
})
const emit = defineEmits(['chatUpdated'])
const userInput = ref('')
const messages = ref([])
const isLoading = ref(false)
const chatContainer = ref(null)
async function sendMessage() {
if (!userInput.value) return;
try {
isLoading.value = true;
const response = await fetch('http://127.0.0.1:3000/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
message: userInput.value
})
});
if (!response.ok) {
if (response.status === 401) {
messages.value.push({
role: 'system',
content: 'Пожалуйста, подключите кошелек для сохранения истории чата'
});
} else {
throw new Error('Network response was not ok')
}
}
const data = await response.json()
messages.value.push({ role: 'user', content: userInput.value })
messages.value.push({ role: 'assistant', content: data.response })
userInput.value = ''
emit('chatUpdated')
} catch (error) {
console.error('Ошибка при отправке сообщения:', error)
messages.value.push({
role: 'system',
content: 'Произошла ошибка при получении ответа'
})
} finally {
isLoading.value = false
}
}
// Загрузка истории чата
async function loadChatHistory() {
try {
if (!props.userAddress) {
console.log('Адрес пользователя не определен');
return;
}
const response = await fetch('http://127.0.0.1:3000/api/chat/history', {
credentials: 'include'
});
if (!response.ok) {
const error = await response.json();
console.log('Ошибка загрузки истории:', error);
if (error.error === 'User not found') {
// Создаем нового пользователя
const createUserResponse = await fetch('http://127.0.0.1:3000/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
address: props.userAddress
})
});
if (!createUserResponse.ok) {
console.error('Ошибка создания пользователя:', await createUserResponse.text());
return;
}
if (createUserResponse.ok) {
// Повторно загружаем историю
return loadChatHistory();
}
}
return;
}
const data = await response.json();
messages.value = (data.history || []).map(chat => ({
role: chat.is_user ? 'user' : 'assistant',
content: chat.is_user ? chat.message : JSON.parse(chat.response).content
}));
} catch (error) {
console.error('Ошибка загрузки истории:', error);
}
}
// Загружаем историю при монтировании
onMounted(() => {
if (props.userAddress) {
loadChatHistory()
}
})
// Добавляем наблюдение за изменением адреса
watch(() => props.userAddress, (newAddress) => {
if (newAddress) {
loadChatHistory()
} else {
messages.value = []
}
})
watch(messages, () => {
if (chatContainer.value) {
setTimeout(() => {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}, 0)
}
}, { deep: true })
</script>
<style scoped>
.empty-state {
text-align: center;
color: #666;
padding: 2rem;
}
.ai-assistant {
margin-top: 20px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chat-container {
height: 300px;
overflow-y: auto;
margin-bottom: 15px;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
}
.message {
margin: 8px 0;
padding: 8px 12px;
border-radius: 4px;
}
.user {
background-color: #e3f2fd;
margin-left: 20%;
}
.assistant {
background-color: #f5f5f5;
margin-right: 20%;
}
.system {
background-color: #ffebee;
text-align: center;
}
.input-container {
display: flex;
gap: 10px;
}
input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #cccccc;
}
</style>

View File

@@ -1,281 +0,0 @@
<template>
<div class="data-tables">
<h3>Данные из базы</h3>
<!-- История чатов -->
<div class="table-section">
<h4>История чатов</h4>
<div v-if="displayedChats.length === 0" class="empty-state">
Нет доступных сообщений
</div>
<table v-else>
<thead>
<tr>
<th>Адрес</th>
<th>Сообщение</th>
<th>Ответ</th>
<th>Дата</th>
<th v-if="isAdmin">Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="chat in displayedChats" :key="chat.id">
<td>{{ shortenAddress(chat.address) }}</td>
<td>{{ chat.message }}</td>
<td>{{ JSON.parse(chat.response).content }}</td>
<td>{{ formatDate(chat.created_at) }}</td>
<td v-if="isAdmin">
<button
@click="approveChat(chat.id)"
:disabled="chat.is_approved"
:class="{ approved: chat.is_approved }"
>
{{ chat.is_approved ? 'Одобрен' : 'Одобрить' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Пользователи -->
<div class="table-section">
<h4>Пользователи</h4>
<table>
<thead>
<tr>
<th>ID</th>
<th>Адрес</th>
<th>Дата регистрации</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ shortenAddress(user.address) }}</td>
<td>{{ formatDate(user.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="isAdmin" class="chat-history">
<h2>История сообщений всех пользователей</h2>
<table>
<thead>
<tr>
<th>Адрес</th>
<th>Сообщение</th>
<th>Ответ</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
<tr v-for="chat in allChats" :key="chat.id">
<td>{{ shortenAddress(chat.address) }}</td>
<td>{{ chat.message }}</td>
<td>{{ JSON.parse(chat.response).content }}</td>
<td>{{ new Date(chat.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import contractABI from '../../artifacts/MyContract.json'
const props = defineProps({
isConnected: Boolean,
userAddress: String
});
const allChats = ref([]);
const users = ref([]);
const isAdmin = ref(false);
const displayedChats = computed(() => {
return isAdmin.value ? allChats.value : allChats.value.filter(
chat => chat.address.toLowerCase() === props.userAddress?.toLowerCase()
);
});
// Нормализация адреса (приведение к нижнему регистру)
function normalizeAddress(address) {
return address?.toLowerCase() || '';
}
// Следим за изменением состояния подключения
watch(() => props.isConnected, (newValue) => {
console.log('isConnected изменился:', newValue);
if (newValue) {
// Небольшая задержка для обновления сессии
setTimeout(() => {
fetchData();
}, 500);
}
});
// Следим за изменением адреса
watch(() => props.userAddress, (newValue) => {
console.log('userAddress изменился:', newValue);
if (props.isConnected && newValue) {
fetchData();
}
});
// Получение данных
async function fetchData() {
try {
console.log('Запрос обновления данных');
if (!props.userAddress) {
console.log('Адрес пользователя не определен');
return;
}
// Проверяем права админа
const adminCheck = await fetch('http://127.0.0.1:3000/api/admin/check', {
credentials: 'include'
});
if (adminCheck.ok) {
const { isAdmin: adminStatus } = await adminCheck.json();
isAdmin.value = adminStatus;
console.log('Статус админа:', adminStatus);
}
// Получаем чаты
const chatsResponse = await fetch('http://127.0.0.1:3000/api/admin/chats', {
credentials: 'include'
});
if (chatsResponse.ok) {
const data = await chatsResponse.json();
allChats.value = data.chats || [];
}
// Получаем пользователей
const usersResponse = await fetch('http://127.0.0.1:3000/api/users', {
credentials: 'include'
});
if (usersResponse.ok) {
const data = await usersResponse.json();
users.value = data.users || [];
}
} catch (error) {
console.error('Ошибка получения данных:', error);
}
}
// Форматирование адреса
function shortenAddress(address) {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
// Форматирование даты
function formatDate(date) {
return new Date(date).toLocaleString();
}
async function approveChat(chatId) {
try {
const response = await fetch('http://127.0.0.1:3000/api/admin/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ chatId })
});
if (!response.ok) throw new Error('Failed to approve chat');
// Обновляем статус локально
const chat = allChats.value.find(c => c.id === chatId);
if (chat) chat.is_approved = true;
} catch (error) {
console.error('Error approving chat:', error);
alert('Ошибка при одобрении чата');
}
}
onMounted(() => {
if (props.isConnected) {
fetchData();
}
});
// Делаем метод доступным извне
defineExpose({
fetchData
});
async function fetchAllChats() {
try {
const response = await fetch('http://127.0.0.1:3000/api/admin/chats', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
allChats.value = data.chats;
}
} catch (error) {
console.error('Ошибка получения чатов:', error);
}
}
onMounted(() => {
if (props.isAdmin) {
fetchAllChats();
}
});
watch(() => props.isAdmin, (newValue) => {
if (newValue) {
fetchAllChats();
}
});
</script>
<style scoped>
.data-tables {
margin: 20px;
}
.table-section {
margin-bottom: 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f5f5f5;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.empty-state {
text-align: center;
color: #666;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
margin: 1rem 0;
}
</style>

View File

@@ -1,254 +0,0 @@
<template>
<div class="vector-store">
<h2>Векторное хранилище</h2>
<div v-if="loading" class="loading">
Загрузка...
</div>
<div v-else>
<div class="stats">
<p>Всего документов: {{ vectors.length }}</p>
<p>Размерность эмбеддингов: {{ getEmbeddingStats }}</p>
</div>
<div class="filters">
<input
v-model="search"
placeholder="Поиск по содержанию..."
class="search-input"
>
<select v-model="typeFilter" class="type-filter">
<option value="">Все типы</option>
<option value="approved_chat">Одобренные чаты</option>
</select>
</div>
<table class="vectors-table">
<thead>
<tr>
<th>ID</th>
<th>Содержание</th>
<th>Метаданные</th>
<th>Дата создания</th>
</tr>
</thead>
<tbody>
<tr v-for="vector in filteredVectors" :key="vector.id">
<td>{{ vector.id }}</td>
<td class="content-cell">
<div class="qa-format">
<div class="question">Q: {{ getQuestion(vector.content) }}</div>
<div class="answer">A: {{ getAnswer(vector.content) }}</div>
</div>
</td>
<td>
<div class="metadata">
<span class="metadata-item">
<strong>Тип:</strong> {{ vector.metadata.type }}
</span>
<span class="metadata-item">
<strong>Одобрил:</strong> {{ shortenAddress(vector.metadata.approvedBy) }}
</span>
<span class="metadata-item">
<strong>ID чата:</strong> {{ vector.metadata.chatId }}
</span>
</div>
</td>
<td>{{ formatDate(vector.created) }}</td>
</tr>
</tbody>
</table>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</div>
</template>
<script>
const API_BASE_URL = 'http://localhost:3000';
export default {
name: 'VectorStore',
data() {
return {
vectors: [],
loading: true,
error: null,
search: '',
typeFilter: '',
baseUrl: API_BASE_URL
}
},
computed: {
getEmbeddingStats() {
if (!this.vectors.length) return 'Нет данных';
const sizes = this.vectors.map(v => v.embedding_size);
const uniqueSizes = [...new Set(sizes)];
if (uniqueSizes.length === 1) {
return uniqueSizes[0];
}
return `Разные размерности: ${uniqueSizes.join(', ')}`;
},
filteredVectors() {
return this.vectors.filter(vector => {
const matchesSearch = this.search === '' ||
vector.content.toLowerCase().includes(this.search.toLowerCase());
const matchesType = this.typeFilter === '' ||
vector.metadata.type === this.typeFilter;
return matchesSearch && matchesType;
});
}
},
async mounted() {
await this.loadVectors();
},
methods: {
async loadVectors() {
try {
const response = await fetch('http://127.0.0.1:3000/api/admin/vectors', {
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (response.status === 401) {
this.error = 'Необходима авторизация. Пожалуйста, подключите кошелек.';
return;
}
if (response.status === 403) {
this.error = 'Доступ запрещен. Только владелец контракта может просматривать векторное хранилище.';
return;
}
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
if (!Array.isArray(data.vectors)) {
throw new Error('Неверный формат данных');
}
this.vectors = data.vectors;
this.error = null;
} catch (error) {
console.error('Error loading vectors:', error);
this.error = `Ошибка загрузки векторов: ${error.message}.
${error.message.includes('404') ?
'API эндпоинт не найден. Проверьте URL и работу сервера.' :
'Проверьте права доступа и подключение к серверу.'}`;
} finally {
this.loading = false;
}
},
getQuestion(content) {
return content.split('\nA:')[0].replace('Q:', '').trim();
},
getAnswer(content) {
return content.split('\nA:')[1]?.trim() || '';
},
shortenAddress(address) {
return address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '';
},
formatDate(date) {
return new Date(date).toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
}
</script>
<style scoped>
.vector-store {
padding: 20px;
}
.filters {
margin: 20px 0;
display: flex;
gap: 10px;
}
.search-input,
.type-filter {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.search-input {
flex: 1;
}
.vectors-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.vectors-table th,
.vectors-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.vectors-table th {
background-color: #f5f5f5;
}
.content-cell {
max-width: 400px;
}
.qa-format {
white-space: pre-wrap;
}
.question {
color: #2c3e50;
margin-bottom: 5px;
}
.answer {
color: #34495e;
}
.metadata {
display: flex;
flex-direction: column;
gap: 5px;
}
.metadata-item {
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
}
.stats {
margin: 20px 0;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.error-message {
margin-top: 20px;
padding: 10px;
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c00;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="access-control">
<div v-if="!isConnected" class="alert alert-warning">
Подключите ваш кошелек для проверки доступа
</div>
<div v-else-if="loading" class="alert alert-info">
Проверка доступа...
</div>
<div v-else-if="error" class="alert alert-danger">
{{ error }}
</div>
<div v-else-if="accessInfo.hasAccess" class="alert alert-success">
<strong>Доступ разрешен!</strong>
<div>Токен: {{ accessInfo.token }}</div>
<div>Роль: {{ accessInfo.role }}</div>
<div>Истекает: {{ formatDate(accessInfo.expiresAt) }}</div>
</div>
<div v-else class="alert alert-danger">
<strong>Доступ запрещен!</strong>
<p>У вас нет активного токена доступа.</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useEthereum } from '../composables/useEthereum';
import axios from 'axios';
const { address, isConnected } = useEthereum();
const loading = ref(false);
const error = ref(null);
const accessInfo = ref({
hasAccess: false,
token: '',
role: '',
expiresAt: null
});
// Форматирование даты
function formatDate(timestamp) {
if (!timestamp) return 'Н/Д';
return new Date(timestamp).toLocaleString();
}
// Проверка доступа
async function checkAccess() {
if (!isConnected.value || !address.value) return;
loading.value = true;
error.value = null;
try {
const response = await axios.get('/access/check', {
headers: {
'x-wallet-address': address.value
}
});
accessInfo.value = response.data;
} catch (err) {
console.error('Ошибка проверки доступа:', err);
error.value = err.response?.data?.error || 'Ошибка проверки доступа';
accessInfo.value = { hasAccess: false };
} finally {
loading.value = false;
}
}
// Проверяем доступ при изменении адреса
watch(() => address.value, () => {
checkAccess();
});
// Проверяем доступ при монтировании компонента
onMounted(() => {
if (isConnected.value && address.value) {
checkAccess();
}
});
</script>

View File

@@ -0,0 +1,194 @@
<template>
<div class="card">
<div class="card-header">
<h5>Проверка доступа</h5>
</div>
<div class="card-body">
<div v-if="!isConnected" class="alert alert-warning">
Подключите ваш кошелек для проверки доступа
</div>
<div v-else>
<div class="mb-3">
<h6>Статус доступа:</h6>
<div v-if="loading" class="alert alert-info">
Проверка доступа...
</div>
<div v-else-if="error" class="alert alert-danger">
{{ error }}
</div>
<div v-else-if="accessInfo.hasAccess" class="alert alert-success">
<strong>Доступ разрешен!</strong>
<div>Токен: {{ accessInfo.token }}</div>
<div>Роль: {{ accessInfo.role }}</div>
<div>Истекает: {{ formatDate(accessInfo.expiresAt) }}</div>
</div>
<div v-else class="alert alert-danger">
<strong>Доступ запрещен!</strong>
<p>У вас нет активного токена доступа.</p>
</div>
</div>
<div class="mb-3">
<h6>Тестирование API:</h6>
<div class="d-grid gap-2">
<button
@click="testPublicAPI"
class="btn btn-primary mb-2"
>
Тест публичного API
</button>
<button
@click="testProtectedAPI"
class="btn btn-warning mb-2"
>
Тест защищенного API
</button>
<button
@click="testAdminAPI"
class="btn btn-danger"
>
Тест админского API
</button>
</div>
</div>
<div v-if="apiResult" class="mt-3">
<h6>Результат запроса:</h6>
<div :class="['alert', apiResult.success ? 'alert-success' : 'alert-danger']">
<strong>{{ apiResult.message }}</strong>
<div v-if="apiResult.data">
<pre>{{ JSON.stringify(apiResult.data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useEthereum } from './useEthereum';
import axios from 'axios';
const { address, isConnected } = useEthereum();
const loading = ref(false);
const error = ref(null);
const accessInfo = ref({
hasAccess: false,
token: '',
role: '',
expiresAt: null
});
const apiResult = ref(null);
// Форматирование даты
function formatDate(timestamp) {
if (!timestamp) return 'Н/Д';
return new Date(timestamp).toLocaleString();
}
// Проверка доступа
async function checkAccess() {
if (!isConnected.value || !address.value) return;
loading.value = true;
error.value = null;
try {
const response = await axios.get('/api/access/check', {
headers: {
'x-wallet-address': address.value
}
});
accessInfo.value = response.data;
} catch (err) {
console.error('Ошибка проверки доступа:', err);
error.value = err.response?.data?.error || 'Ошибка проверки доступа';
accessInfo.value = { hasAccess: false };
} finally {
loading.value = false;
}
}
// Тест публичного API
async function testPublicAPI() {
apiResult.value = null;
try {
const response = await axios.get('/api/public');
apiResult.value = {
success: true,
message: 'Публичный API доступен',
data: response.data
};
} catch (err) {
apiResult.value = {
success: false,
message: 'Ошибка доступа к публичному API',
data: err.response?.data
};
}
}
// Тест защищенного API
async function testProtectedAPI() {
apiResult.value = null;
try {
const response = await axios.get('/api/protected', {
headers: {
'x-wallet-address': address.value
}
});
apiResult.value = {
success: true,
message: 'Защищенный API доступен',
data: response.data
};
} catch (err) {
apiResult.value = {
success: false,
message: 'Ошибка доступа к защищенному API',
data: err.response?.data
};
}
}
// Тест админского API
async function testAdminAPI() {
apiResult.value = null;
try {
const response = await axios.get('/api/admin', {
headers: {
'x-wallet-address': address.value
}
});
apiResult.value = {
success: true,
message: 'Админский API доступен',
data: response.data
};
} catch (err) {
apiResult.value = {
success: false,
message: 'Ошибка доступа к админскому API',
data: err.response?.data
};
}
}
// Проверяем доступ при изменении адреса
watch(() => address.value, () => {
checkAccess();
});
// Проверяем доступ при монтировании компонента
onMounted(() => {
if (isConnected.value && address.value) {
checkAccess();
}
});
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление токенами доступа</h5>
</div>
<div class="card-body">
<div v-if="!isConnected" class="alert alert-warning">
Подключите ваш кошелек для управления токенами
</div>
<div v-else-if="loading" class="alert alert-info">
Загрузка...
</div>
<div v-else>
<h6>Создать новый токен</h6>
<form @submit.prevent="createToken" class="mb-4">
<div class="mb-3">
<label for="walletAddress" class="form-label">Адрес кошелька</label>
<input
type="text"
class="form-control"
id="walletAddress"
v-model="newToken.walletAddress"
placeholder="0x..."
required
/>
</div>
<div class="mb-3">
<label for="role" class="form-label">Роль</label>
<select class="form-select" id="role" v-model="newToken.role" required>
<option value="USER">Пользователь</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div class="mb-3">
<label for="expiresAt" class="form-label">Срок действия (дни)</label>
<input
type="number"
class="form-control"
id="expiresAt"
v-model="newToken.expiresInDays"
min="1"
max="365"
required
/>
</div>
<button type="submit" class="btn btn-primary">Создать токен</button>
</form>
<h6>Активные токены</h6>
<div v-if="tokens.length === 0" class="alert alert-info">
Нет активных токенов
</div>
<div v-else class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Адрес</th>
<th>Роль</th>
<th>Истекает</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td>{{ token.id }}</td>
<td>{{ shortenAddress(token.walletAddress) }}</td>
<td>{{ token.role }}</td>
<td>{{ formatDate(token.expiresAt) }}</td>
<td>
<button
@click="revokeToken(token.id)"
class="btn btn-sm btn-danger"
>
Отозвать
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useEthereum } from './useEthereum';
import axios from 'axios';
const { address, isConnected } = useEthereum();
const loading = ref(false);
const tokens = ref([]);
const newToken = ref({
walletAddress: '',
role: 'USER',
expiresInDays: 30
});
// Сокращение адреса кошелька
function shortenAddress(addr) {
if (!addr) return '';
return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
}
// Форматирование даты
function formatDate(timestamp) {
if (!timestamp) return 'Н/Д';
return new Date(timestamp).toLocaleString();
}
// Загрузка токенов
async function loadTokens() {
if (!isConnected.value || !address.value) return;
loading.value = true;
try {
const response = await axios.get('/api/access/tokens', {
headers: {
'x-wallet-address': address.value
}
});
tokens.value = response.data;
} catch (err) {
console.error('Ошибка загрузки токенов:', err);
alert('Ошибка загрузки токенов: ' + (err.response?.data?.error || err.message));
} finally {
loading.value = false;
}
}
// Создание токена
async function createToken() {
if (!isConnected.value || !address.value) return;
loading.value = true;
try {
await axios.post('/api/access/tokens',
{
walletAddress: newToken.value.walletAddress,
role: newToken.value.role,
expiresInDays: parseInt(newToken.value.expiresInDays)
},
{
headers: {
'x-wallet-address': address.value
}
}
);
// Сбрасываем форму
newToken.value = {
walletAddress: '',
role: 'USER',
expiresInDays: 30
};
// Перезагружаем список токенов
await loadTokens();
alert('Токен успешно создан');
} catch (err) {
console.error('Ошибка создания токена:', err);
alert('Ошибка создания токена: ' + (err.response?.data?.error || err.message));
} finally {
loading.value = false;
}
}
// Отзыв токена
async function revokeToken(tokenId) {
if (!isConnected.value || !address.value) return;
if (!confirm('Вы уверены, что хотите отозвать этот токен?')) {
return;
}
loading.value = true;
try {
await axios.delete(`/api/access/tokens/${tokenId}`, {
headers: {
'x-wallet-address': address.value
}
});
// Перезагружаем список токенов
await loadTokens();
alert('Токен успешно отозван');
} catch (err) {
console.error('Ошибка отзыва токена:', err);
alert('Ошибка отзыва токена: ' + (err.response?.data?.error || err.message));
} finally {
loading.value = false;
}
}
// Загружаем токены при монтировании компонента
onMounted(() => {
if (isConnected.value && address.value) {
loadTokens();
}
});
</script>

View File

@@ -0,0 +1,298 @@
<template>
<div class="ai-chat">
<div v-if="!isAuthenticated" class="connect-wallet-message">
Для отправки сообщений необходимо подключить кошелек
</div>
<div class="chat-messages" ref="messagesContainer">
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.role]">
{{ message.content }}
</div>
</div>
<div class="chat-input">
<textarea
v-model="userInput"
@keydown.enter.prevent="sendMessage"
placeholder="Введите ваше сообщение..."
:disabled="!isAuthenticated"
:class="{ 'disabled': !isAuthenticated }"
></textarea>
<button
@click="sendMessage"
:disabled="!isAuthenticated || !userInput.trim()"
:class="{ 'disabled': !isAuthenticated }"
>
{{ isAuthenticated ? 'Отправить' : 'Подключите кошелек' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useRouter } from 'vue-router';
import axios from 'axios';
const auth = useAuthStore();
const router = useRouter();
const messages = ref([]);
const userInput = ref('');
const messagesContainer = ref(null);
const isAuthenticated = computed(() => auth.isAuthenticated);
const currentUserAddress = computed(() => auth.address);
async function checkAndRefreshSession() {
try {
// Проверяем, есть ли активная сессия
const sessionResponse = await fetch('/api/session', {
credentials: 'include'
});
if (!sessionResponse.ok) {
console.error('Ошибка при проверке сессии:', sessionResponse.status, sessionResponse.statusText);
// Проверяем, доступен ли сервер
try {
const pingResponse = await fetch('/api/debug/ping');
if (!pingResponse.ok) {
throw new Error(`Сервер недоступен: ${pingResponse.status} ${pingResponse.statusText}`);
}
const pingData = await pingResponse.json();
console.log('Ping response:', pingData);
} catch (pingError) {
console.error('Ошибка при проверке доступности сервера:', pingError);
throw new Error('Сервер недоступен. Пожалуйста, убедитесь, что сервер запущен и доступен.');
}
// Пробуем восстановить из localStorage
if (auth.restoreAuth()) {
console.log('Сессия восстановлена из localStorage в Chats');
return true;
}
throw new Error(`Ошибка сервера: ${sessionResponse.status} ${sessionResponse.statusText}`);
}
const sessionData = await sessionResponse.json();
console.log('Проверка сессии в Chats:', sessionData);
// Проверяем аутентификацию
if (sessionData.isAuthenticated || sessionData.authenticated) {
// Сессия активна, обновляем состояние auth store
auth.setAuth(sessionData.address, sessionData.isAdmin);
return true;
} else {
// Сессия не активна, пробуем восстановить из localStorage
if (auth.restoreAuth()) {
console.log('Сессия восстановлена из localStorage в Chats');
return true;
}
// Если не удалось восстановить, выбрасываем ошибку
throw new Error('Необходимо переподключить кошелек');
}
} catch (error) {
console.log('Session check error:', error);
throw error;
}
}
async function sendMessage() {
if (!userInput.value.trim() || !isAuthenticated.value) return;
const currentMessage = userInput.value.trim();
userInput.value = '';
// Добавляем сообщение пользователя в чат
messages.value.push({
role: 'user',
content: currentMessage
});
// Прокручиваем чат вниз
scrollToBottom();
try {
console.log('Отправка сообщения в Ollama:', currentMessage);
// Добавляем индикатор загрузки
messages.value.push({
role: 'system',
content: 'Загрузка ответа...'
});
// Прокручиваем чат вниз
scrollToBottom();
// Отправляем запрос к серверу
const response = await fetch('/api/chat/ollama', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: currentMessage,
model: 'mistral' // Указываем модель Mistral
}),
credentials: 'include'
});
// Удаляем индикатор загрузки
messages.value.pop();
// Проверяем статус ответа
if (!response.ok) {
let errorMessage = 'Ошибка при отправке сообщения';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch (jsonError) {
console.error('Ошибка при парсинге JSON ответа об ошибке:', jsonError);
}
throw new Error(errorMessage);
}
const data = await response.json();
console.log('Ответ от сервера:', data);
// Добавляем ответ от сервера в чат
messages.value.push({
role: 'assistant',
content: data.response
});
// Прокручиваем чат вниз
scrollToBottom();
} catch (error) {
console.error('Error details:', error);
// Добавляем сообщение об ошибке в чат
messages.value.push({
role: 'system',
content: `Ошибка: ${error.message}`
});
// Прокручиваем чат вниз
scrollToBottom();
}
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}
onMounted(async () => {
// Проверяем сессию
await checkAndRefreshSession();
// Загружаем историю сообщений
// ...
});
</script>
<style scoped>
.ai-chat {
display: flex;
flex-direction: column;
height: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.connect-wallet-message {
background-color: #fff3e0;
color: #e65100;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
border: 1px solid #ffe0b2;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
}
.message {
margin-bottom: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px;
}
.message.user {
background-color: #e3f2fd;
margin-left: 2rem;
}
.message.assistant {
background-color: #f5f5f5;
margin-right: 2rem;
}
.message.system {
background-color: #ffebee;
text-align: center;
}
.chat-input {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
textarea.disabled {
background-color: #f5f5f5;
border-color: #ddd;
color: #999;
cursor: not-allowed;
}
textarea.disabled::placeholder {
color: #999;
}
button.disabled {
background-color: #e0e0e0;
color: #999;
cursor: not-allowed;
}
textarea {
flex: 1;
min-height: 60px;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
transition: all 0.3s ease;
}
button {
padding: 0.5rem 1rem;
background-color: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
}
button:not(.disabled):hover {
background-color: #1565c0;
}
</style>

View File

@@ -1,162 +0,0 @@
<template>
<div class="contract-deploy">
<h2>Деплой смарт-контракта</h2>
<form @submit.prevent="deployContract" class="deploy-form">
<div class="form-group">
<label>Начальная цена (ETH)</label>
<input
v-model="initialPrice"
type="number"
step="0.001"
min="0"
required
class="form-input"
/>
</div>
<div class="form-group">
<label>Название токена</label>
<input
v-model="tokenName"
type="text"
required
class="form-input"
/>
</div>
<div class="form-group">
<label>Символ токена</label>
<input
v-model="tokenSymbol"
type="text"
required
class="form-input"
/>
</div>
<button
type="submit"
:disabled="isDeploying"
class="deploy-button"
>
{{ isDeploying ? 'Деплой...' : 'Деплой контракта' }}
</button>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="deployedAddress" class="success-message">
Контракт развернут по адресу: {{ deployedAddress }}
</div>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ethers } from 'ethers'
import contractABI from '../../artifacts/MyContract.json'
const initialPrice = ref(0.01)
const tokenName = ref('')
const tokenSymbol = ref('')
const isDeploying = ref(false)
const error = ref('')
const deployedAddress = ref('')
async function deployContract() {
if (!window.ethereum) {
error.value = 'MetaMask не установлен'
return
}
try {
isDeploying.value = true
error.value = ''
const provider = new ethers.BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
// Создаем фабрику контракта
const factory = new ethers.ContractFactory(
contractABI.abi,
contractABI.bytecode,
signer
)
// Деплоим контракт
const contract = await factory.deploy(
ethers.parseEther(initialPrice.value.toString()),
tokenName.value,
tokenSymbol.value
)
await contract.waitForDeployment()
deployedAddress.value = await contract.getAddress()
} catch (err) {
console.error('Ошибка деплоя:', err)
error.value = err.message
} finally {
isDeploying.value = false
}
}
</script>
<style scoped>
.contract-deploy {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.deploy-form {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #2c3e50;
}
.form-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.deploy-button {
width: 100%;
padding: 10px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.deploy-button:disabled {
background-color: #cccccc;
}
.error-message {
color: #dc3545;
margin-top: 10px;
}
.success-message {
color: #28a745;
margin-top: 10px;
}
</style>

View File

@@ -1,771 +0,0 @@
<template>
<div class="contract-interaction">
<h2>Взаимодействие с контрактом</h2>
<div v-if="!isInitialized" class="loading-message">
Загрузка контракта...
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="isCorrectNetwork" class="wallet-info">
<p>Адрес кошелька: {{ address }}</p>
<div class="contract-controls">
<h3>Управление контрактом</h3>
<p v-if="currentPrice" class="price-info">
Текущая цена: {{ formatPrice(currentPrice) }} ETH
</p>
<!-- Панель управления для владельца -->
<div v-if="isOwner" class="owner-controls">
<h4>Панель владельца</h4>
<div class="input-group">
<input
v-model="newPrice"
type="number"
step="0.001"
placeholder="Новая цена (ETH)"
class="amount-input"
/>
<button
@click="handleSetPrice"
:disabled="!newPrice || isLoading"
class="admin-button"
>
Установить цену
</button>
</div>
<button
@click="handleWithdraw"
:disabled="isLoading"
class="admin-button withdraw-button"
>
Вывести средства
</button>
</div>
<!-- Панель покупки -->
<div class="purchase-panel">
<h4>Покупка</h4>
<div class="input-group">
<input
v-model="amount"
type="number"
placeholder="Введите количество"
class="amount-input"
/>
<button
@click="handlePurchase"
:disabled="!amount || isLoading"
class="purchase-button"
>
{{ isLoading ? 'Обработка...' : 'Купить' }}
</button>
</div>
<p v-if="amount && currentPrice" class="total-cost">
Общая стоимость: {{ formatPrice(calculateTotalCost()) }} ETH
</p>
</div>
<p v-if="success" class="success-message">{{ success }}</p>
</div>
</div>
<h3>Управление смарт-контрактом</h3>
<div class="contract-info">
<p>Адрес контракта: {{ contractAddress }}</p>
<p>Начальная цена: {{ initialPrice }} ETH</p>
<p>Владелец: {{ contractOwner }}</p>
</div>
<div class="contract-actions">
<button
v-if="isOwner"
@click="withdrawFunds"
:disabled="withdrawing"
>
{{ withdrawing ? 'Вывод средств...' : 'Вывести средства' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { BrowserProvider, Contract, JsonRpcProvider, formatEther, parseEther, getAddress } from 'ethers'
import { ethers } from 'ethers'
const props = defineProps({
userAddress: String
})
// Инициализируем все ref переменные в начале
const address = ref('')
const currentPrice = ref(null)
const contractOwner = ref(null)
const amount = ref(1)
const newPrice = ref('')
const isLoading = ref(false)
const error = ref('')
const success = ref('')
const isCorrectNetwork = ref(false)
const isConnected = ref(false)
const isInitialized = ref(false)
const walletProvider = ref(null)
const isAuthenticated = ref(false)
const statement = 'Sign in with Ethereum to access DApp features and AI Assistant'
const initialPrice = ref('0.009')
const contractAddress = '0x9acac5926e86fE2d7A69deff27a4b2D310108d76' // Адрес из логов сервера
const withdrawing = ref(false)
// Константы
const SEPOLIA_CHAIN_ID = 11155111
const provider = new JsonRpcProvider(import.meta.env.VITE_APP_ETHEREUM_NETWORK_URL)
const contractABI = [
{
"inputs": [{"internalType": "uint256", "name": "amount", "type": "uint256"}],
"name": "purchase",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "price",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"internalType": "uint256", "name": "newPrice", "type": "uint256"}],
"name": "setPrice",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{"indexed": false, "internalType": "address", "name": "buyer", "type": "address"},
{"indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256"}
],
"name": "Purchase",
"type": "event"
}
]
// Вычисляемые свойства
const isOwner = computed(() => {
return address.value && contractOwner.value &&
address.value.toLowerCase() === contractOwner.value.toLowerCase()
})
// Функции
function formatPrice(price) {
if (!price) return '0'
return formatEther(price)
}
// Функция инициализации контракта
async function initializeContract() {
try {
if (!contractAddress) {
throw new Error('Contract address not configured')
}
const provider = new JsonRpcProvider(import.meta.env.VITE_APP_ETHEREUM_NETWORK_URL)
const contract = new Contract(
contractAddress,
contractABI,
provider
)
await Promise.all([
contract.price().then(price => {
currentPrice.value = price
console.log('Начальная цена:', formatEther(price), 'ETH')
}),
contract.owner().then(owner => {
contractOwner.value = owner
console.log('Владелец контракта:', owner)
})
])
isInitialized.value = true
} catch (err) {
console.error('Ошибка при инициализации контракта:', err)
error.value = 'Ошибка при инициализации контракта: ' + err.message
}
}
// Функция подключения к MetaMask
async function connectWallet() {
try {
// Проверяем доступность MetaMask
if (!window.ethereum) {
throw new Error('MetaMask не установлен')
}
// Запрашиваем доступ к аккаунту
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
})
// Сохраняем адрес и провайдер
address.value = accounts[0]
walletProvider.value = window.ethereum
isConnected.value = true
// SIWE аутентификация
try {
// Получаем nonce
const nonceResponse = await fetch(
'http://127.0.0.1:3000/nonce',
{ credentials: 'include' }
);
const { nonce } = await nonceResponse.json();
// Сохраняем nonce в localStorage
localStorage.setItem('siwe-nonce', nonce);
// Создаем сообщение для подписи
const message =
`${window.location.host} wants you to sign in with your Ethereum account:\n` +
`${getAddress(address.value)}\n\n` +
`${statement}\n\n` +
`URI: http://${window.location.host}\n` +
`Version: 1\n` +
`Chain ID: 11155111\n` +
`Nonce: ${nonce}\n` +
`Issued At: ${new Date().toISOString()}\n` +
`Resources:\n` +
`- http://${window.location.host}/api/chat\n` +
`- http://${window.location.host}/api/contract`;
// Получаем подпись
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, address.value]
});
// Верифицируем подпись
const verifyResponse = await fetch(
'http://127.0.0.1:3000/verify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-SIWE-Nonce': nonce
},
credentials: 'include',
body: JSON.stringify({ message, signature })
}
);
if (!verifyResponse.ok) {
throw new Error('Failed to verify signature');
}
} catch (error) {
console.error('SIWE error:', error);
throw new Error('Ошибка аутентификации');
}
// Подписываемся на изменение аккаунта
window.ethereum.on('accountsChanged', handleAccountsChanged)
window.ethereum.on('chainChanged', handleChainChanged)
await checkNetwork()
} catch (err) {
console.error('Ошибка при подключении кошелька:', err)
error.value = 'Ошибка при подключении кошелька: ' + err.message
}
}
// Обработчики событий MetaMask
function handleAccountsChanged(accounts) {
if (accounts.length === 0) {
// MetaMask отключен
isConnected.value = false
address.value = null
} else {
// Аккаунт изменен
address.value = accounts[0]
}
}
function handleChainChanged() {
// При смене сети перезагружаем страницу
window.location.reload()
}
// Обновляем watch
watch(isConnected, async (newValue) => {
try {
if (newValue) {
console.log('Кошелек подключен, адрес:', address.value)
if (!isInitialized.value) {
await initializeContract()
}
await checkNetwork()
} else {
console.log('Кошелек отключен')
currentPrice.value = null
contractOwner.value = null
isCorrectNetwork.value = false
error.value = ''
isInitialized.value = false
}
} catch (err) {
console.error('Ошибка при обработке подключения:', err)
error.value = 'Ошибка при обработке подключения: ' + err.message
}
}, { immediate: true })
// Обновляем функцию проверки сети
async function checkNetwork() {
try {
if (!walletProvider.value) {
isCorrectNetwork.value = false
error.value = 'Провайдер кошелька недоступен'
return
}
const ethersProvider = new BrowserProvider(walletProvider.value)
const network = await ethersProvider.getNetwork()
console.log('Текущая сеть:', network.chainId)
isCorrectNetwork.value = Number(network.chainId) === SEPOLIA_CHAIN_ID
if (!isCorrectNetwork.value) {
error.value = `Пожалуйста, переключитесь на сеть Sepolia (${SEPOLIA_CHAIN_ID}). Текущая сеть: ${network.chainId}`
} else {
error.value = ''
await Promise.all([fetchPrice(), fetchOwner()])
}
} catch (err) {
console.error('Ошибка при проверке сети:', err)
isCorrectNetwork.value = false
error.value = 'Ошибка при проверке сети: ' + err.message
}
}
// Обновляем функцию fetchPrice с обработкой ошибок
async function fetchPrice() {
try {
const contract = new Contract(contractAddress, contractABI, provider)
const price = await contract.price()
currentPrice.value = price
console.log('Текущая цена:', formatEther(price), 'ETH')
} catch (err) {
console.error('Ошибка при получении цены:', err)
error.value = `Не удалось получить текущую цену: ${err.message}`
currentPrice.value = null
}
}
// Получение адреса владельца
async function fetchOwner() {
try {
const contract = new Contract(contractAddress, contractABI, provider)
contractOwner.value = await contract.owner()
} catch (err) {
console.error('Ошибка при получении адреса владельца:', err)
}
}
// Обновляем onMounted
onMounted(async () => {
console.log('Компонент смонтирован')
try {
await initializeContract()
if (provider) {
const contract = new Contract(contractAddress, contractABI, provider)
contract.on('Purchase', (buyer, amount) => {
console.log(`Новая покупка: ${amount} единиц от ${buyer}`)
fetchPrice()
})
return () => {
contract.removeAllListeners('Purchase')
}
}
} catch (err) {
console.error('Ошибка при монтировании компонента:', err)
}
})
// Добавляем функцию для расчета общей стоимости
function calculateTotalCost() {
if (!currentPrice.value || !amount.value) return BigInt(0)
return currentPrice.value * BigInt(amount.value)
}
// Обновляем handlePurchase
async function handlePurchase() {
if (!amount.value) return
if (!isCorrectNetwork.value) {
error.value = 'Пожалуйста, переключитесь на сеть Sepolia'
return
}
error.value = ''
success.value = ''
try {
isLoading.value = true
const ethersProvider = new BrowserProvider(walletProvider.value)
const signer = await ethersProvider.getSigner()
const contract = new Contract(contractAddress, contractABI, signer)
const totalCost = calculateTotalCost()
// Проверяем баланс
const balance = await ethersProvider.getBalance(await signer.getAddress())
console.log('Баланс кошелька:', formatEther(balance), 'ETH')
if (balance < totalCost) {
throw new Error(`Недостаточно средств. Нужно ${formatEther(totalCost)} ETH`)
}
console.log('Общая стоимость:', formatEther(totalCost), 'ETH')
console.log('Параметры транзакции:', {
amount: amount.value,
totalCost: formatEther(totalCost),
from: await signer.getAddress()
})
const tx = await contract.purchase(amount.value, {
value: totalCost,
gasLimit: 100000 // Явно указываем лимит газа
})
console.log('Транзакция отправлена:', tx.hash)
await tx.wait()
console.log('Транзакция подтверждена')
amount.value = ''
success.value = 'Покупка успешно совершена!'
await fetchPrice()
} catch (err) {
console.error('Ошибка при покупке:', err)
console.error('Детали ошибки:', {
code: err.code,
message: err.message,
data: err.data,
reason: err.reason
})
if (err.message.includes('user rejected')) {
error.value = 'Транзакция отменена пользователем'
} else if (err.message.includes('Недостаточно средств')) {
error.value = err.message
} else {
error.value = 'Произошла ошибка при совершении покупки: ' + err.message
}
} finally {
isLoading.value = false
}
}
// Добавляем новые функции
async function handleSetPrice() {
if (!newPrice.value) return
error.value = ''
success.value = ''
try {
isLoading.value = true
const ethersProvider = new BrowserProvider(walletProvider.value)
const signer = await ethersProvider.getSigner()
const contract = new Contract(contractAddress, contractABI, signer)
const priceInWei = parseEther(newPrice.value.toString())
const tx = await contract.setPrice(priceInWei)
await tx.wait()
newPrice.value = ''
success.value = 'Цена успешно обновлена!'
await fetchPrice()
} catch (err) {
console.error('Ошибка при установке цены:', err)
error.value = 'Ошибка при установке цены: ' + err.message
} finally {
isLoading.value = false
}
}
async function handleWithdraw() {
error.value = ''
success.value = ''
try {
isLoading.value = true
const ethersProvider = new BrowserProvider(walletProvider.value)
const signer = await ethersProvider.getSigner()
const contract = new Contract(contractAddress, contractABI, signer)
const tx = await contract.withdraw()
await tx.wait()
success.value = 'Средства успешно выведены!'
} catch (err) {
console.error('Ошибка при выводе средств:', err)
error.value = 'Ошибка при выводе средств: ' + err.message
} finally {
isLoading.value = false
}
}
async function disconnectWallet() {
try {
// Выходим из системы на сервере
const response = await fetch('http://127.0.0.1:3000/api/signout', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to sign out');
}
// Отключаем кошелек
if (window.ethereum) {
await window.ethereum.request({
method: 'wallet_requestPermissions',
params: [{ eth_accounts: {} }]
});
}
// Сбрасываем состояние
isConnected.value = false;
address.value = '';
// Перезагружаем страницу для очистки состояния
window.location.reload();
} catch (error) {
console.error('Error disconnecting wallet:', error);
alert('Ошибка при отключении кошелька');
}
}
defineExpose({
isConnected,
address
})
</script>
<style scoped>
.contract-interaction {
padding: 20px;
max-width: 600px;
margin: 0 auto;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.wallet-info {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 8px;
}
.contract-controls {
margin-top: 20px;
}
.input-group {
display: flex;
gap: 10px;
margin-top: 10px;
}
.amount-input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.purchase-button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.purchase-button:hover:not(:disabled) {
background-color: #45a049;
}
.purchase-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.error-message {
color: #dc3545;
padding: 10px;
margin: 10px 0;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.price-info {
margin: 10px 0;
font-weight: bold;
color: #2c3e50;
}
.owner-controls {
margin-top: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.purchase-panel {
margin-top: 20px;
}
.admin-button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.admin-button:hover:not(:disabled) {
background-color: #0056b3;
}
.withdraw-button {
margin-top: 10px;
background-color: #6c757d;
}
.withdraw-button:hover:not(:disabled) {
background-color: #5a6268;
}
.total-cost {
margin-top: 10px;
font-size: 0.9em;
color: #6c757d;
}
.success-message {
color: #28a745;
margin-top: 10px;
font-size: 0.9em;
}
.loading-message {
color: #6c757d;
text-align: center;
padding: 20px;
background-color: #f8f9fa;
border-radius: 4px;
margin: 10px 0;
}
.auth-status {
margin: 10px 0;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.authenticated {
color: #28a745;
}
.signout-button {
padding: 5px 10px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.signout-button:hover {
background-color: #c82333;
}
.connect-button {
padding: 10px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.connect-button:hover {
background-color: #45a049;
}
.wallet-status {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.disconnect-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.disconnect-btn:hover {
background-color: #c82333;
}
.contract-info {
text-align: left;
margin-bottom: 1rem;
}
.contract-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="linked-accounts">
<h2>Связанные аккаунты</h2>
<div v-if="loading" class="loading">
Загрузка...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else>
<div v-if="identities.length === 0" class="no-accounts">
У вас нет связанных аккаунтов.
</div>
<div v-else class="accounts-list">
<div v-for="identity in identities" :key="`${identity.identity_type}-${identity.identity_value}`" class="account-item">
<div class="account-type">
{{ getIdentityTypeLabel(identity.identity_type) }}
</div>
<div class="account-value">
{{ formatIdentityValue(identity) }}
</div>
<button @click="unlinkAccount(identity)" class="unlink-button">
Отвязать
</button>
</div>
</div>
<div class="link-instructions">
<h3>Как связать аккаунты</h3>
<div class="instruction">
<h4>Telegram</h4>
<p>Отправьте боту команду:</p>
<code>/link {{ userAddress }}</code>
</div>
<div class="instruction">
<h4>Email</h4>
<p>Отправьте письмо на адрес бота с темой:</p>
<code>link {{ userAddress }}</code>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'LinkedAccounts',
data() {
return {
loading: true,
error: null,
identities: [],
userAddress: ''
};
},
async mounted() {
this.userAddress = this.$store.state.auth.address;
await this.loadIdentities();
},
methods: {
async loadIdentities() {
try {
this.loading = true;
this.error = null;
const response = await axios.get('/api/identities', {
withCredentials: true
});
this.identities = response.data.identities;
} catch (error) {
console.error('Error loading identities:', error);
this.error = 'Не удалось загрузить связанные аккаунты. Попробуйте позже.';
} finally {
this.loading = false;
}
},
async unlinkAccount(identity) {
try {
await axios.delete(`/api/identities/${identity.identity_type}/${identity.identity_value}`, {
withCredentials: true
});
// Обновляем список идентификаторов
await this.loadIdentities();
} catch (error) {
console.error('Error unlinking account:', error);
alert('Не удалось отвязать аккаунт. Попробуйте позже.');
}
},
getIdentityTypeLabel(type) {
const labels = {
ethereum: 'Ethereum',
telegram: 'Telegram',
email: 'Email'
};
return labels[type] || type;
},
formatIdentityValue(identity) {
if (identity.identity_type === 'ethereum') {
// Сокращаем Ethereum-адрес
const value = identity.identity_value;
return `${value.substring(0, 6)}...${value.substring(value.length - 4)}`;
}
return identity.identity_value;
}
}
};
</script>
<style scoped>
.linked-accounts {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.loading, .error, .no-accounts {
margin: 20px 0;
padding: 10px;
text-align: center;
}
.error {
color: #e74c3c;
border: 1px solid #e74c3c;
border-radius: 4px;
}
.accounts-list {
margin: 20px 0;
}
.account-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.account-type {
font-weight: bold;
width: 100px;
}
.account-value {
flex: 1;
}
.unlink-button {
background-color: #e74c3c;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.link-instructions {
margin-top: 30px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
.instruction {
margin-bottom: 15px;
}
code {
display: block;
padding: 10px;
background-color: #eee;
border-radius: 4px;
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="modal-backdrop" @click="$emit('close')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<slot name="header">Заголовок</slot>
<button class="close-button" @click="$emit('close')">&times;</button>
</div>
<div class="modal-body">
<slot name="body">Содержимое</slot>
</div>
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Modal',
emits: ['close']
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
font-weight: bold;
font-size: 1.2rem;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.close-button:hover {
color: #000;
}
.modal-body {
padding: 1rem;
}
.modal-footer {
padding: 1rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -1,180 +0,0 @@
<template>
<div class="sidebar" :class="{ 'collapsed': isSidebarCollapsed }">
<div class="sidebar-content">
<!-- AI Assistant Section -->
<div class="sidebar-section">
<div class="section-header" @click="toggleSidebarOrSection">
<span v-if="!isSidebarCollapsed">
<span>ИИ Ассистент</span>
</span>
<span v-else class="section-letter">AI</span>
</div>
<div class="section-content" v-show="!isSidebarCollapsed">
<SidebarItem
to="/ai/chats"
text="Чаты"
/>
<template v-if="isAdmin">
<SidebarItem
to="/ai/users"
text="Пользователи"
/>
<SidebarItem
to="/ai/vectorstore"
text="Векторное хранилище"
/>
</template>
</div>
</div>
<!-- Smart Contract Section -->
<div v-if="isAdmin" class="sidebar-section">
<div class="section-header" @click="toggleSidebarOrSection">
<span v-if="!isSidebarCollapsed">
<span>Смарт Контракт</span>
</span>
<span v-else class="section-letter">SK</span>
</div>
<div class="section-content" v-show="!isSidebarCollapsed">
<SidebarItem
to="/contract/deploy"
icon="🚀"
text="Деплой"
/>
<SidebarItem
to="/contract/manage"
icon="⚙️"
text="Управление"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import SidebarItem from './SidebarItem.vue'
const props = defineProps({
isAdmin: {
type: Boolean,
required: true
}
})
const emit = defineEmits(['update:collapsed'])
const aiExpanded = ref(true)
const contractExpanded = ref(true)
const isSidebarCollapsed = ref(false)
function toggleAI() {
aiExpanded.value = !aiExpanded.value
}
function toggleContract() {
contractExpanded.value = !contractExpanded.value
}
function toggleSidebar() {
isSidebarCollapsed.value = !isSidebarCollapsed.value
emit('update:collapsed', isSidebarCollapsed.value)
}
function toggleSidebarOrSection(event) {
if (isSidebarCollapsed.value) {
isSidebarCollapsed.value = false
emit('update:collapsed', false)
}
}
</script>
<style scoped>
.sidebar {
width: 250px;
background: white;
color: #2c3e50;
padding: 1.5rem 0;
position: fixed;
top: 70px;
left: 0;
overflow-y: auto;
z-index: 100;
transition: all 0.3s ease;
height: calc(100vh - 70px);
}
.sidebar.collapsed {
width: 60px;
padding: 1rem 0;
background: #2c3e50;
}
.sidebar-section {
margin-bottom: 1rem;
color: #2c3e50;
}
.section-header {
display: flex;
align-items: center;
padding: 0.75rem 2rem;
cursor: pointer;
transition: background-color 0.3s;
color: #2c3e50;
font-weight: 500;
height: 44px;
user-select: none;
}
.section-header:hover {
background-color: rgba(0,0,0,0.05);
}
.icon {
display: none;
}
.section-content {
margin-left: 2rem;
padding-top: 0.25rem;
}
/* Стили для активных ссылок */
:deep(.router-link-active) {
background-color: rgba(0,0,0,0.1);
}
.section-letter {
color: white;
font-weight: bold;
font-size: 1.2rem;
cursor: pointer;
display: block;
text-align: center;
line-height: 40px;
height: 40px;
}
.collapsed .section-header {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.collapsed .section-header:hover .section-letter {
opacity: 0.8;
}
/* Стили для пунктов меню */
:deep(.sidebar-link) {
height: 36px;
display: flex;
align-items: center;
padding: 0 0.75rem;
}
</style>

View File

@@ -1,73 +0,0 @@
<template>
<router-link
:to="to"
class="sidebar-item"
:class="{ 'router-link-active': $route.path === to }"
custom
v-slot="{ navigate, isActive }"
>
<div
@click="navigate"
class="sidebar-link"
:class="{ active: isActive }"
>
<span class="text">{{ text }}</span>
</div>
</router-link>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router/dist/vue-router.esm-bundler'
const props = defineProps({
to: {
type: String,
required: true
},
icon: {
type: String,
default: '📄'
},
text: {
type: String,
required: true
}
})
const route = useRoute()
const isActive = computed(() => route.path === props.to)
</script>
<style scoped>
.sidebar-link {
display: flex;
align-items: center;
padding: 0 0.75rem;
color: #2c3e50;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
cursor: pointer;
height: 36px;
}
.sidebar-link:hover {
background-color: rgba(0,0,0,0.05);
}
.sidebar-link.active {
background-color: rgba(0,0,0,0.1);
font-weight: 500;
}
.icon {
margin-right: 0.5rem;
}
.text {
font-size: 0.9rem;
color: inherit;
line-height: 1;
}
</style>

View File

@@ -1,243 +1,51 @@
<template> <template>
<div class="wallet-connection"> <button @click.stop.prevent="connect" class="auth-btn wallet-btn">
<div v-if="!isConnected" class="header"> <span class="auth-icon">💼</span> Подключить кошелек
<h1>DApp for Business</h1>
<button
@click="connectWallet"
class="connect-button"
>
Подключить кошелек
</button> </button>
</div>
<div v-else class="header">
<h1>DApp for Business</h1>
<div class="wallet-info">
<span class="address">{{ shortenAddress(userAddress) }}</span>
<button @click="disconnectWallet" class="disconnect-btn">
Отключить
</button>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { connectWallet } from '../services/wallet';
import { ref } from 'vue';
const props = defineProps({ const error = ref('');
isConnected: Boolean,
userAddress: String function connect() {
console.log('Нажата кнопка "Подключить кошелек"');
connectWallet((errorMessage) => {
error.value = errorMessage;
console.error('Ошибка при подключении кошелька:', errorMessage);
}); });
const emit = defineEmits(['connect', 'disconnect']);
// Проверка сессии при загрузке
async function checkSession() {
try {
const response = await fetch('http://127.0.0.1:3000/api/session', {
credentials: 'include'
});
if (!response.ok) return;
const data = await response.json();
if (data.authenticated) {
emit('connect', data.address);
} }
} catch (error) {
console.error('Ошибка проверки сессии:', error);
}
}
async function connectWallet() {
try {
if (!window.ethereum) {
alert('Пожалуйста, установите MetaMask для работы с приложением');
window.open('https://metamask.io/download.html', '_blank');
return;
}
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (accounts.length > 0) {
const address = accounts[0];
// Получаем nonce
const nonceResponse = await fetch('http://127.0.0.1:3000/api/nonce', {
credentials: 'include'
});
if (!nonceResponse.ok) {
throw new Error('Failed to get nonce');
}
const { nonce } = await nonceResponse.json();
// Создаем сообщение для подписи
const message = {
domain: window.location.host,
address: address,
statement: 'Sign in with Ethereum to access DApp features and AI Assistant',
uri: window.location.origin,
version: '1',
chainId: '11155111',
nonce: nonce,
issuedAt: new Date().toISOString(),
resources: [
`${window.location.origin}/api/chat`,
`${window.location.origin}/api/contract`
]
};
console.log('Подписываем сообщение:', message);
// Получаем подпись
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [
JSON.stringify(message, null, 2),
address
]
});
console.log('Получена подпись:', signature);
// Верифицируем подпись
const verifyResponse = await fetch('http://127.0.0.1:3000/api/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
message,
signature
})
});
if (!verifyResponse.ok) {
const error = await verifyResponse.text();
throw new Error(`Failed to verify signature: ${error}`);
}
const { isAdmin } = await verifyResponse.json();
emit('connect', address);
return isAdmin;
}
} catch (error) {
console.error('Ошибка подключения:', error);
alert('Ошибка подключения кошелька: ' + error.message);
}
}
async function disconnectWallet() {
try {
// Выходим из системы
const response = await fetch('http://127.0.0.1:3000/api/signout', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to sign out');
}
emit('disconnect');
} catch (error) {
console.error('Error disconnecting:', error);
alert('Ошибка при отключении');
}
}
function shortenAddress(address) {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
onMounted(() => {
checkSession();
});
</script> </script>
<style scoped> <style scoped>
.wallet-connection { /* Стили для кнопки подключения кошелька */
.auth-btn {
display: flex; display: flex;
justify-content: space-between;
padding: 1.5rem 2rem;
background: white;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 99;
box-sizing: border-box;
}
.header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
} justify-content: flex-start;
padding: 0.75rem 1rem;
h1 {
margin: 0;
font-size: 1.5rem;
color: #2c3e50;
font-weight: 600;
line-height: 1;
}
.connect-button {
padding: 0.5rem 1rem;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
min-width: 180px;
text-align: center;
}
.connect-btn,
.disconnect-btn {
padding: 0.5rem 1rem;
height: 36px;
line-height: 1;
border: none; border: none;
border-radius: 4px; width: 100%;
cursor: pointer; font-weight: 500;
transition: opacity 0.2s;
}
.auth-btn:hover {
opacity: 0.9;
}
.auth-icon {
margin-right: 0.75rem;
font-size: 1.2rem;
}
.wallet-btn {
background-color: #1976d2;
color: white; color: white;
} }
.connect-btn {
background: #28a745;
}
.disconnect-btn {
padding: 0.5rem 1rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.wallet-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.address {
font-family: monospace;
background: #e9ecef;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
</style> </style>

View File

@@ -0,0 +1,128 @@
<template>
<form @submit.prevent="submitForm" class="add-board-form">
<div class="form-group">
<label for="title">Название доски</label>
<input
type="text"
id="title"
v-model="form.title"
class="form-control"
required
placeholder="Введите название доски"
>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
placeholder="Введите описание доски"
></textarea>
</div>
<div class="form-group form-check">
<input
type="checkbox"
id="isPublic"
v-model="form.isPublic"
class="form-check-input"
>
<label for="isPublic" class="form-check-label">Публичная доска</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Отмена</button>
<button type="submit" class="btn btn-primary" :disabled="!form.title">Создать</button>
</div>
</form>
</template>
<script>
import { ref } from 'vue';
import axios from 'axios';
export default {
name: 'AddBoardForm',
emits: ['add-board', 'cancel'],
setup(props, { emit }) {
const form = ref({
title: '',
description: '',
isPublic: false
});
const submitForm = async () => {
try {
const response = await axios.post('/api/kanban/boards', {
title: form.value.title,
description: form.value.description,
isPublic: form.value.isPublic
});
emit('add-board', response.data);
form.value = { title: '', description: '', isPublic: false };
} catch (error) {
console.error('Error creating board:', error);
alert('Не удалось создать доску');
}
};
const cancel = () => {
emit('cancel');
};
return {
form,
submitForm,
cancel
};
}
}
</script>
<style scoped>
.add-board-form {
padding: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-check-label {
font-weight: normal;
}
.form-check-input {
margin-top: 0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<form @submit.prevent="submitForm" class="add-card-form">
<div class="form-group">
<label for="title">Заголовок</label>
<input
type="text"
id="title"
v-model="form.title"
class="form-control"
required
placeholder="Введите заголовок карточки"
>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
placeholder="Введите описание карточки"
></textarea>
</div>
<div class="form-group">
<label for="dueDate">Срок выполнения</label>
<input
type="date"
id="dueDate"
v-model="form.dueDate"
class="form-control"
>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Отмена</button>
<button type="submit" class="btn btn-primary" :disabled="!form.title">Создать</button>
</div>
</form>
</template>
<script>
import { ref } from 'vue';
import axios from 'axios';
export default {
name: 'AddCardForm',
props: {
columnId: {
type: Number,
required: true
}
},
emits: ['add-card', 'cancel'],
setup(props, { emit }) {
const form = ref({
title: '',
description: '',
dueDate: ''
});
const submitForm = async () => {
try {
const response = await axios.post('/api/kanban/cards', {
title: form.value.title,
description: form.value.description,
columnId: props.columnId,
dueDate: form.value.dueDate || null
});
emit('add-card', response.data);
form.value = { title: '', description: '', dueDate: '' };
} catch (error) {
console.error('Error creating card:', error);
alert('Не удалось создать карточку');
}
};
const cancel = () => {
emit('cancel');
};
return {
form,
submitForm,
cancel
};
}
}
</script>
<style scoped>
.add-card-form {
padding: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<form @submit.prevent="submitForm" class="add-column-form">
<div class="form-group">
<label for="title">Название колонки</label>
<input
type="text"
id="title"
v-model="form.title"
class="form-control"
required
placeholder="Введите название колонки"
>
</div>
<div class="form-group">
<label for="wipLimit">Лимит WIP (опционально)</label>
<input
type="number"
id="wipLimit"
v-model="form.wipLimit"
class="form-control"
min="1"
placeholder="Введите лимит задач в работе"
>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Отмена</button>
<button type="submit" class="btn btn-primary" :disabled="!form.title">Создать</button>
</div>
</form>
</template>
<script>
import { ref } from 'vue';
import axios from 'axios';
export default {
name: 'AddColumnForm',
props: {
boardId: {
type: [Number, String],
required: true
}
},
emits: ['add-column', 'cancel'],
setup(props, { emit }) {
const form = ref({
title: '',
wipLimit: null
});
const submitForm = async () => {
try {
const response = await axios.post(`/api/kanban/boards/${props.boardId}/columns`, {
title: form.value.title,
wipLimit: form.value.wipLimit || null
});
// Добавляем пустой массив карточек для отображения в UI
const columnWithCards = {
...response.data,
cards: []
};
emit('add-column', columnWithCards);
form.value = { title: '', wipLimit: null };
} catch (error) {
console.error('Error creating column:', error);
alert('Не удалось создать колонку');
}
};
const cancel = () => {
emit('cancel');
};
return {
form,
submitForm,
cancel
};
}
}
</script>
<style scoped>
.add-column-form {
padding: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,529 @@
<template>
<div class="kanban-board">
<div class="board-header">
<h1>{{ board.title }}</h1>
<p v-if="board.description">{{ board.description }}</p>
<div class="board-actions">
<button @click="showAddCardModal = true" class="btn btn-primary">
Добавить карточку
</button>
<button @click="showBoardSettings = true" class="btn btn-secondary">
Настройки доски
</button>
</div>
</div>
<div class="columns-container">
<div
v-for="column in board.columns"
:key="column.id"
class="kanban-column"
:class="{ 'wip-limit-reached': column.wip_limit && column.cards.length >= column.wip_limit }"
>
<div class="column-header">
<h3>{{ column.title }}</h3>
<span v-if="column.wip_limit" class="wip-limit">
{{ column.cards.length }}/{{ column.wip_limit }}
</span>
<div class="column-actions">
<button @click="showAddCardModal = true; selectedColumn = column" class="btn-icon">
<i class="fas fa-plus"></i>
</button>
<button @click="showColumnSettings = true; selectedColumn = column" class="btn-icon">
<i class="fas fa-cog"></i>
</button>
</div>
</div>
<div class="cards-container">
<draggable
v-model="column.cards"
group="cards"
@change="onCardMove"
:animation="150"
ghost-class="ghost-card"
class="column-cards"
>
<kanban-card
v-for="card in column.cards"
:key="card.id"
:card="card"
@click="openCard(card)"
/>
</draggable>
</div>
</div>
<div class="add-column">
<button @click="showAddColumnModal = true" class="btn btn-outline">
<i class="fas fa-plus"></i> Добавить колонку
</button>
</div>
</div>
<!-- Модальные окна -->
<modal v-if="showAddCardModal" @close="showAddCardModal = false">
<template #header>Добавить карточку</template>
<template #body>
<add-card-form
:column-id="selectedColumn ? selectedColumn.id : null"
@add-card="addCard"
@cancel="showAddCardModal = false"
/>
</template>
</modal>
<modal v-if="showAddColumnModal" @close="showAddColumnModal = false">
<template #header>Добавить колонку</template>
<template #body>
<add-column-form
:board-id="board.id"
@add-column="addColumn"
@cancel="showAddColumnModal = false"
/>
</template>
</modal>
<modal v-if="showBoardSettings" @close="showBoardSettings = false">
<template #header>Настройки доски</template>
<template #body>
<board-settings-form
:board="board"
@update-board="updateBoard"
@cancel="showBoardSettings = false"
/>
</template>
</modal>
<modal v-if="showColumnSettings" @close="showColumnSettings = false">
<template #header>Настройки колонки</template>
<template #body>
<column-settings-form
:column="selectedColumn"
@update-column="updateColumn"
@delete-column="deleteColumn"
@cancel="showColumnSettings = false"
/>
</template>
</modal>
<card-detail-modal
v-if="selectedCard"
:card="selectedCard"
:column="getColumnForCard(selectedCard)"
@close="selectedCard = null"
@update-card="updateCard"
@delete-card="deleteCard"
/>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue';
import axios from 'axios';
import draggable from 'vuedraggable';
import KanbanCard from './KanbanCard.vue';
import Modal from '../ui/Modal.vue';
import AddCardForm from './AddCardForm.vue';
import AddColumnForm from './AddColumnForm.vue';
import BoardSettingsForm from './BoardSettingsForm.vue';
import ColumnSettingsForm from './ColumnSettingsForm.vue';
import CardDetailModal from './CardDetailModal.vue';
export default {
name: 'KanbanBoard',
components: {
draggable,
KanbanCard,
Modal,
AddCardForm,
AddColumnForm,
BoardSettingsForm,
ColumnSettingsForm,
CardDetailModal
},
props: {
boardId: {
type: String,
required: true
}
},
setup(props) {
const board = reactive({
id: null,
title: '',
description: '',
columns: []
});
const loading = ref(false);
const error = ref(null);
const showAddCardModal = ref(false);
const showAddColumnModal = ref(false);
const showBoardSettings = ref(false);
const showColumnSettings = ref(false);
const selectedColumn = ref(null);
const selectedCard = ref(null);
// Загрузка данных доски
const loadBoard = async () => {
try {
loading.value = true;
error.value = null;
const response = await axios.get(`/api/kanban/boards/${props.boardId}`, {
withCredentials: true
});
// Обновляем реактивный объект board
Object.assign(board, response.data);
} catch (err) {
console.error('Error loading board:', err);
error.value = 'Не удалось загрузить доску. Попробуйте позже.';
} finally {
loading.value = false;
}
};
// Обработка перемещения карточки
const onCardMove = async (event) => {
try {
if (event.added) {
const { element: card, newIndex } = event.added;
// Обновляем позицию и колонку карточки в БД
await axios.put(`/api/kanban/cards/${card.id}/move`, {
columnId: selectedColumn.value.id,
position: newIndex
}, {
withCredentials: true
});
}
} catch (err) {
console.error('Error moving card:', err);
// В случае ошибки перезагружаем доску
await loadBoard();
}
};
// Добавление новой карточки
const addCard = async (cardData) => {
try {
const response = await axios.post('/api/kanban/cards', cardData, {
withCredentials: true
});
// Находим колонку и добавляем в нее новую карточку
const column = board.columns.find(col => col.id === cardData.columnId);
if (column) {
column.cards.push(response.data);
}
showAddCardModal.value = false;
} catch (err) {
console.error('Error adding card:', err);
error.value = 'Не удалось добавить карточку. Попробуйте позже.';
}
};
// Добавление новой колонки
const addColumn = async (columnData) => {
try {
const response = await axios.post('/api/kanban/columns', {
...columnData,
boardId: board.id
}, {
withCredentials: true
});
// Добавляем новую колонку в список
board.columns.push({
...response.data,
cards: []
});
showAddColumnModal.value = false;
} catch (err) {
console.error('Error adding column:', err);
error.value = 'Не удалось добавить колонку. Попробуйте позже.';
}
};
// Обновление настроек доски
const updateBoard = async (boardData) => {
try {
const response = await axios.put(`/api/kanban/boards/${board.id}`, boardData, {
withCredentials: true
});
// Обновляем данные доски
Object.assign(board, response.data);
showBoardSettings.value = false;
} catch (err) {
console.error('Error updating board:', err);
error.value = 'Не удалось обновить настройки доски. Попробуйте позже.';
}
};
// Обновление настроек колонки
const updateColumn = async (columnData) => {
try {
const response = await axios.put(`/api/kanban/columns/${columnData.id}`, columnData, {
withCredentials: true
});
// Находим и обновляем колонку
const columnIndex = board.columns.findIndex(col => col.id === columnData.id);
if (columnIndex !== -1) {
board.columns[columnIndex] = {
...board.columns[columnIndex],
...response.data
};
}
showColumnSettings.value = false;
} catch (err) {
console.error('Error updating column:', err);
error.value = 'Не удалось обновить настройки колонки. Попробуйте позже.';
}
};
// Удаление колонки
const deleteColumn = async (columnId) => {
try {
await axios.delete(`/api/kanban/columns/${columnId}`, {
withCredentials: true
});
// Удаляем колонку из списка
const columnIndex = board.columns.findIndex(col => col.id === columnId);
if (columnIndex !== -1) {
board.columns.splice(columnIndex, 1);
}
showColumnSettings.value = false;
} catch (err) {
console.error('Error deleting column:', err);
error.value = 'Не удалось удалить колонку. Попробуйте позже.';
}
};
// Открытие карточки для просмотра/редактирования
const openCard = (card) => {
selectedCard.value = card;
};
// Получение колонки для карточки
const getColumnForCard = (card) => {
return board.columns.find(col => col.id === card.column_id);
};
// Обновление карточки
const updateCard = async (cardData) => {
try {
const response = await axios.put(`/api/kanban/cards/${cardData.id}`, cardData, {
withCredentials: true
});
// Находим и обновляем карточку
for (const column of board.columns) {
const cardIndex = column.cards.findIndex(c => c.id === cardData.id);
if (cardIndex !== -1) {
column.cards[cardIndex] = {
...column.cards[cardIndex],
...response.data
};
break;
}
}
// Если открыта эта карточка, обновляем ее
if (selectedCard.value && selectedCard.value.id === cardData.id) {
selectedCard.value = {
...selectedCard.value,
...response.data
};
}
} catch (err) {
console.error('Error updating card:', err);
error.value = 'Не удалось обновить карточку. Попробуйте позже.';
}
};
// Удаление карточки
const deleteCard = async (cardId) => {
try {
await axios.delete(`/api/kanban/cards/${cardId}`, {
withCredentials: true
});
// Удаляем карточку из списка
for (const column of board.columns) {
const cardIndex = column.cards.findIndex(c => c.id === cardId);
if (cardIndex !== -1) {
column.cards.splice(cardIndex, 1);
break;
}
}
selectedCard.value = null;
} catch (err) {
console.error('Error deleting card:', err);
error.value = 'Не удалось удалить карточку. Попробуйте позже.';
}
};
onMounted(() => {
loadBoard();
});
return {
board,
loading,
error,
showAddCardModal,
showAddColumnModal,
showBoardSettings,
showColumnSettings,
selectedColumn,
selectedCard,
onCardMove,
addCard,
addColumn,
updateBoard,
updateColumn,
deleteColumn,
openCard,
getColumnForCard,
updateCard,
deleteCard
};
}
};
</script>
<style scoped>
.kanban-board {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.board-header {
padding: 1rem;
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.board-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.columns-container {
display: flex;
overflow-x: auto;
padding: 1rem;
height: calc(100vh - 150px);
}
.kanban-column {
min-width: 280px;
max-width: 280px;
margin-right: 1rem;
background-color: #f0f0f0;
border-radius: 4px;
display: flex;
flex-direction: column;
max-height: 100%;
}
.column-header {
padding: 0.75rem;
background-color: #e0e0e0;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.column-actions {
display: flex;
gap: 0.25rem;
}
.cards-container {
padding: 0.5rem;
overflow-y: auto;
flex-grow: 1;
}
.column-cards {
min-height: 10px;
}
.wip-limit {
font-size: 0.8rem;
color: #666;
margin-left: 0.5rem;
}
.wip-limit-reached .wip-limit {
color: #e74c3c;
font-weight: bold;
}
.add-column {
min-width: 280px;
display: flex;
align-items: flex-start;
padding: 0.5rem;
}
.ghost-card {
opacity: 0.5;
background: #c8ebfb;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
border: none;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-outline {
background-color: transparent;
border: 1px dashed #95a5a6;
color: #666;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
color: #666;
padding: 0.25rem;
}
.btn-icon:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="kanban-card" :class="{ 'has-due-date': hasDueDate, 'overdue': isOverdue }">
<div class="card-labels" v-if="card.labels && card.labels.length > 0">
<span
v-for="label in card.labels"
:key="label.id"
class="card-label"
:style="{ backgroundColor: label.color }"
:title="label.name"
></span>
</div>
<div class="card-title">{{ card.title }}</div>
<div class="card-footer">
<div class="card-due-date" v-if="hasDueDate" :title="formattedDueDate">
<i class="far fa-clock"></i> {{ dueDateDisplay }}
</div>
<div class="card-assignee" v-if="card.assigned_username">
<span class="avatar" :title="card.assigned_username">
{{ getInitials(card.assigned_username) }}
</span>
</div>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
export default {
name: 'KanbanCard',
props: {
card: {
type: Object,
required: true
}
},
setup(props) {
// Проверяем, есть ли срок выполнения
const hasDueDate = computed(() => {
return !!props.card.due_date;
});
// Проверяем, просрочена ли задача
const isOverdue = computed(() => {
if (!props.card.due_date) return false;
const dueDate = new Date(props.card.due_date);
const now = new Date();
return dueDate < now;
});
// Форматируем дату для отображения
const formattedDueDate = computed(() => {
if (!props.card.due_date) return '';
const dueDate = new Date(props.card.due_date);
return dueDate.toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
});
// Сокращенное отображение даты
const dueDateDisplay = computed(() => {
if (!props.card.due_date) return '';
const dueDate = new Date(props.card.due_date);
const now = new Date();
// Если сегодня
if (dueDate.toDateString() === now.toDateString()) {
return 'Сегодня ' + dueDate.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
});
}
// Если завтра
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
if (dueDate.toDateString() === tomorrow.toDateString()) {
return 'Завтра ' + dueDate.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
});
}
// В остальных случаях
return dueDate.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short'
});
});
// Получаем инициалы пользователя
const getInitials = (name) => {
if (!name) return '';
return name
.split(' ')
.map(part => part.charAt(0).toUpperCase())
.join('')
.substring(0, 2);
};
return {
hasDueDate,
isOverdue,
formattedDueDate,
dueDateDisplay,
getInitials
};
}
}
</script>
<style scoped>
.kanban-card {
background-color: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
padding: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.kanban-card:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
}
.card-labels {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 0.5rem;
}
.card-label {
width: 32px;
height: 8px;
border-radius: 4px;
}
.card-title {
font-weight: 500;
margin-bottom: 0.5rem;
word-break: break-word;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #666;
}
.card-due-date {
display: flex;
align-items: center;
gap: 4px;
}
.has-due-date.overdue .card-due-date {
color: #e74c3c;
font-weight: bold;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #3498db;
color: white;
border-radius: 50%;
font-size: 0.7rem;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,151 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ethers } from 'ethers'
export function useEthereum() {
const address = ref(null)
const isConnected = ref(false)
const chainId = ref(null)
const provider = ref(null)
const signer = ref(null)
// Инициализация при загрузке компонента
onMounted(async () => {
// Проверяем, есть ли сохраненное состояние подключения
const savedAddress = localStorage.getItem('walletAddress')
const savedConnected = localStorage.getItem('isConnected') === 'true'
if (savedConnected && savedAddress) {
// Пробуем восстановить подключение
try {
await connect()
} catch (error) {
console.error('Failed to restore connection:', error)
// Очищаем сохраненное состояние при ошибке
localStorage.removeItem('walletAddress')
localStorage.removeItem('isConnected')
}
}
})
// Функция для подключения кошелька
async function connect() {
try {
// Проверяем, доступен ли MetaMask
if (typeof window.ethereum === 'undefined') {
throw new Error('MetaMask не установлен')
}
// Запрашиваем доступ к аккаунтам
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })
if (accounts.length === 0) {
throw new Error('Нет доступных аккаунтов')
}
// Устанавливаем адрес
address.value = accounts[0]
try {
// Создаем провайдер и signer (для ethers v5)
provider.value = new ethers.providers.Web3Provider(window.ethereum)
signer.value = provider.value.getSigner()
// Получаем chainId напрямую из ethereum провайдера
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' })
chainId.value = parseInt(chainIdHex, 16).toString()
} catch (providerError) {
console.error('Ошибка при создании провайдера:', providerError)
// Продолжаем выполнение, даже если не удалось получить signer
}
isConnected.value = true
// Сохраняем информацию о подключении
localStorage.setItem('walletConnected', 'true')
localStorage.setItem('walletAddress', address.value)
return address.value
} catch (error) {
console.error('Connection error:', error)
throw error
}
}
// Функция для отключения кошелька
function disconnect() {
address.value = null
isConnected.value = false
provider.value = null
signer.value = null
chainId.value = null
// Очищаем localStorage
localStorage.removeItem('walletAddress')
localStorage.removeItem('isConnected')
}
// Функция для подписи сообщения
async function signMessage(message) {
if (!signer.value) {
throw new Error('Кошелек не подключен')
}
return await signer.value.signMessage(message)
}
// Обработчик изменения аккаунтов
async function handleAccountsChanged(accounts) {
if (accounts.length === 0) {
// Пользователь отключил аккаунт
address.value = null
isConnected.value = false
signer.value = null
} else {
// Пользователь сменил аккаунт
address.value = accounts[0]
isConnected.value = true
// Обновляем signer
if (provider.value) {
signer.value = provider.value.getSigner()
}
}
}
// Обработчик изменения сети
function handleChainChanged(chainIdHex) {
chainId.value = parseInt(chainIdHex, 16).toString()
window.location.reload()
}
// Инициализация при монтировании компонента
onMounted(() => {
// Проверяем, есть ли MetaMask
if (window.ethereum) {
// Добавляем слушатели событий
window.ethereum.on('accountsChanged', handleAccountsChanged)
window.ethereum.on('chainChanged', handleChainChanged)
window.ethereum.on('disconnect', disconnect)
}
})
// Очистка при размонтировании компонента
onUnmounted(() => {
if (window.ethereum) {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged)
window.ethereum.removeListener('chainChanged', handleChainChanged)
window.ethereum.removeListener('disconnect', disconnect)
}
})
return {
address,
isConnected,
chainId,
provider,
signer,
connect,
disconnect,
signMessage
}
}

View File

@@ -1,8 +1,36 @@
import { Buffer } from 'buffer'
globalThis.Buffer = Buffer
import { createApp } from 'vue' import { createApp } from 'vue'
import router from './router/index.js' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router'
import axios from 'axios'
// Вместо этого
axios.defaults.baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
// Используйте относительный путь
axios.defaults.baseURL = ''
// Включение передачи кук
axios.defaults.withCredentials = true
// Создаем и монтируем приложение Vue // Создаем и монтируем приложение Vue
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router) app.use(router)
// Не используем заглушки, так как сервер работает
// if (import.meta.env.DEV) {
// Promise.all([
// import('./mocks/chatApi.js'),
// import('./mocks/authApi.js'),
// import('./mocks/kanbanApi.js'),
// import('./mocks/contractApi.js')
// ]).catch(err => console.error('Failed to load API mocks:', err));
// }
app.mount('#app') app.mount('#app')

69
frontend/src/mocks/api.js Normal file
View File

@@ -0,0 +1,69 @@
// Заглушки для API в режиме разработки
export function setupMockServer() {
if (import.meta.env.DEV) {
// Перехватываем fetch запросы
const originalFetch = window.fetch;
window.fetch = async function(url, options) {
// Проверяем, является ли запрос API запросом
if (typeof url === 'string' && url.includes('/api/')) {
console.log('Перехвачен запрос:', url, options);
// Заглушка для сессии
if (url.includes('/api/session')) {
return new Response(JSON.stringify({
authenticated: true,
address: '0xf45aa4917b3775ba37f48aeb3dc1a943561e9e0b'
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Заглушка для проверки доступности сервера
if (url.includes('/api/debug/ping')) {
return new Response(JSON.stringify({ status: 'ok' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Заглушка для досок Канбан
if (url.includes('/api/kanban/boards')) {
return new Response(JSON.stringify({
ownBoards: [
{
id: 1,
title: 'Разработка DApp',
description: 'Задачи по разработке DApp',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
owner_address: '0xf45aa4917b3775ba37f48aeb3dc1a943561e9e0b',
is_public: true
},
{
id: 2,
title: 'Маркетинг',
description: 'Маркетинговые задачи',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
owner_address: '0xf45aa4917b3775ba37f48aeb3dc1a943561e9e0b',
is_public: false
}
],
sharedBoards: [],
publicBoards: []
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Заглушка для чата с ИИ
if (url.includes('/api/chat/message') && options?.method === 'POST') {
const body = options.body ? JSON.parse(options.body) : {};
return new Response(JSON.stringify({
response: `Это тестовый ответ на ваше сообщение: "${body.message}". В данный момент сервер недоступен.`
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
// Для всех остальных запросов используем оригинальный fetch
return originalFetch.apply(this, arguments);
};
console.log('Заглушки API настроены для режима разработки');
}
}

View File

@@ -0,0 +1,68 @@
// Импортируем axios для перехвата запросов
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
// Создаем экземпляр MockAdapter
const mock = new MockAdapter(axios, { delayResponse: 1000 });
// Мокаем запрос к API для получения nonce
mock.onGet(/\/api\/auth\/nonce/).reply((config) => {
const address = config.url.split('?address=')[1];
if (!address) {
return [400, { error: 'Address is required' }];
}
return [200, {
message: `Sign this message to authenticate with DApp for Business: ${Math.floor(Math.random() * 1000000)}`,
address
}];
});
// Мокаем запрос к API для верификации подписи
mock.onPost('/api/auth/verify').reply((config) => {
const { address } = JSON.parse(config.data);
// Проверяем, является ли адрес администратором (для тестирования)
const isAdmin = address.toLowerCase() === '0xf45aa4917b3775ba37f48aeb3dc1a943561e9e0b'.toLowerCase();
return [200, {
authenticated: true,
address,
isAdmin
}];
});
// Мокаем запрос к API для аутентификации по email
mock.onPost('/api/auth/email').reply((config) => {
const { email } = JSON.parse(config.data);
// Проверяем формат email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return [400, { error: 'Invalid email format' }];
}
// Проверяем, является ли email администратором (для тестирования)
const isAdmin = email.toLowerCase() === 'admin@example.com';
return [200, {
authenticated: true,
address: email,
isAdmin
}];
});
// Мокаем запрос к API для проверки сессии
mock.onGet('/api/auth/check').reply(200, {
authenticated: false,
address: null,
isAdmin: false
});
// Мокаем запрос к API для выхода
mock.onPost('/api/auth/logout').reply(200, {
success: true
});
export default mock;

View File

@@ -0,0 +1,40 @@
// Импортируем axios для перехвата запросов
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
// Создаем экземпляр MockAdapter
const mock = new MockAdapter(axios, { delayResponse: 1000 });
// Мокаем запрос к API чата
mock.onPost('/api/chat/message').reply((config) => {
const { message, userId, language } = JSON.parse(config.data);
// Определяем язык ответа
const isRussian = language === 'ru';
// Простые ответы на разных языках
const responses = {
ru: [
'Я могу помочь вам с различными задачами. Что именно вас интересует?',
'Для полноценной работы рекомендую авторизоваться в системе.',
'Это интересный вопрос! Давайте разберемся подробнее.',
'Я ИИ-ассистент DApp for Business. Чем могу помочь?',
'Для доступа к расширенным функциям необходимо подключить кошелек или авторизоваться другим способом.'
],
en: [
'I can help you with various tasks. What are you interested in?',
'For full functionality, I recommend logging into the system.',
'That\'s an interesting question! Let\'s explore it in more detail.',
'I\'m the AI assistant for DApp for Business. How can I help?',
'To access advanced features, you need to connect your wallet or authorize in another way.'
]
};
// Выбираем случайный ответ из соответствующего языка
const randomIndex = Math.floor(Math.random() * responses[isRussian ? 'ru' : 'en'].length);
const reply = responses[isRussian ? 'ru' : 'en'][randomIndex];
return [200, { reply }];
});
export default mock;

View File

@@ -0,0 +1,46 @@
// Импортируем axios для перехвата запросов
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
// Создаем экземпляр MockAdapter
const mock = new MockAdapter(axios, { delayResponse: 1000 });
// Мокаем запрос к API для создания токена доступа
mock.onPost('/api/contracts/tokens').reply(200, {
success: true,
token: {
id: Math.floor(Math.random() * 1000000).toString(),
address: '0x' + Math.random().toString(16).substring(2, 42),
role: 'user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
}
});
// Мокаем запрос к API для получения списка токенов
mock.onGet('/api/contracts/tokens').reply(200, {
tokens: [
{
id: '1',
address: '0x1234567890abcdef1234567890abcdef12345678',
role: 'admin',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: '2',
address: '0xabcdef1234567890abcdef1234567890abcdef12',
role: 'user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString()
}
]
});
// Обрабатываем все остальные запросы к API смарт-контрактов
mock.onAny(/\/api\/contracts\/.*/).reply(200, {
success: true,
message: 'Операция выполнена успешно'
});
export default mock;

View File

@@ -0,0 +1,96 @@
// Импортируем axios для перехвата запросов
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
// Создаем экземпляр MockAdapter
const mock = new MockAdapter(axios, { delayResponse: 1000 });
// Мокаем запрос к API для получения списка досок
mock.onGet('/api/kanban/boards').reply(200, {
boards: [
{
id: '1',
title: 'Проект 1',
description: 'Описание проекта 1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '2',
title: 'Проект 2',
description: 'Описание проекта 2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]
});
// Мокаем запрос к API для получения конкретной доски
mock.onGet(/\/api\/kanban\/boards\/\d+/).reply((config) => {
const id = config.url.split('/').pop();
return [200, {
board: {
id,
title: `Проект ${id}`,
description: `Описание проекта ${id}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
columns: [
{
id: '1',
title: 'К выполнению',
order: 1,
tasks: [
{
id: '1',
title: 'Задача 1',
description: 'Описание задачи 1',
priority: 'high',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]
},
{
id: '2',
title: 'В процессе',
order: 2,
tasks: [
{
id: '2',
title: 'Задача 2',
description: 'Описание задачи 2',
priority: 'medium',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]
},
{
id: '3',
title: 'Завершено',
order: 3,
tasks: [
{
id: '3',
title: 'Задача 3',
description: 'Описание задачи 3',
priority: 'low',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]
}
]
}
}];
});
// Обрабатываем все остальные запросы к API канбан-досок
mock.onAny(/\/api\/kanban\/.*/).reply(200, {
success: true,
message: 'Операция выполнена успешно'
});
export default mock;

View File

@@ -1,63 +1,82 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router';
import Chats from '../components/AI/Chats.vue' import { useAuthStore } from '../stores/auth';
import Users from '../components/AI/Users.vue' // Импортируем компоненты напрямую, если они существуют
import VectorStore from '../components/AI/VectorStore.vue' import HomeView from '../views/HomeView.vue';
import Deploy from '../components/Contract/Deploy.vue' import DashboardView from '../views/DashboardView.vue';
import Manage from '../components/Contract/Manage.vue' import KanbanView from '../views/KanbanView.vue';
import KanbanBoardView from '../views/KanbanBoardView.vue';
// Защита маршрутов для админа import AccessTestPage from '../views/AccessTestPage.vue';
const requireAdmin = async (to, from, next) => { import ProfileView from '../views/ProfileView.vue';
try {
const response = await fetch('http://127.0.0.1:3000/api/admin/check', {
credentials: 'include'
})
if (response.ok) {
const { isAdmin } = await response.json()
if (isAdmin) {
next()
return
}
}
next('/')
} catch (error) {
console.error('Ошибка проверки прав:', error)
next('/')
}
}
const routes = [ const routes = [
{ {
path: '/', path: '/',
redirect: '/ai/chats' name: 'home',
component: HomeView,
meta: { requiresAuth: false }
}, },
{ {
path: '/ai/chats', path: '/dashboard',
name: 'chats', name: 'dashboard',
component: Chats component: DashboardView,
meta: { requiresAuth: true, requiresAdmin: true }
}, },
{ {
path: '/ai/users', path: '/access-test',
beforeEnter: requireAdmin, name: 'access-test',
component: Users component: AccessTestPage,
meta: { requiresAuth: true, requiresAdmin: true }
},
// Перенаправляем с /chat на главную страницу
{
path: '/chat',
redirect: { name: 'home' }
},
// Маршруты для Канбан-досок
{
path: '/kanban',
name: 'kanban',
component: KanbanView,
meta: { requiresAuth: true }
}, },
{ {
path: '/ai/vectorstore', path: '/kanban/boards/:id',
beforeEnter: requireAdmin, name: 'kanbanBoard',
component: VectorStore component: KanbanBoardView,
meta: { requiresAuth: true }
}, },
{ {
path: '/contract/deploy', path: '/profile',
beforeEnter: requireAdmin, name: 'profile',
component: Deploy component: ProfileView,
}, meta: { requiresAuth: true }
{
path: '/contract/manage',
beforeEnter: requireAdmin,
component: Manage
} }
] ];
export default createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
}) scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 }
}
});
// Навигационный хук для проверки аутентификации
router.beforeEach((to, from, next) => {
const auth = useAuthStore();
// Если маршрут требует аутентификации и пользователь не аутентифицирован
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next({ name: 'home' });
}
// Если маршрут требует прав администратора и пользователь не администратор
else if (to.meta.requiresAdmin && !auth.isAdmin) {
next({ name: 'home' });
}
// В остальных случаях разрешаем переход
else {
next();
}
});
export default router;

View File

@@ -0,0 +1,106 @@
import axios from 'axios';
import { ethers } from 'ethers';
import { useAuthStore } from '../stores/auth';
export async function connectWallet(onError) {
const auth = useAuthStore();
try {
console.log('Начинаем подключение кошелька...');
// Закомментируйте получение CSRF-токена
// const csrfResponse = await axios.get('/api/csrf-token');
// const csrfToken = csrfResponse.data.csrfToken;
// Проверяем, доступен ли MetaMask
if (typeof window.ethereum === 'undefined') {
console.error('MetaMask не установлен');
if (onError) onError('Пожалуйста, установите MetaMask для подключения кошелька.');
return;
}
console.log('MetaMask доступен, запрашиваем аккаунты...');
// Создаем провайдер и получаем подписчика
const provider = new ethers.BrowserProvider(window.ethereum);
// Запрашиваем доступ к аккаунтам
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
if (!accounts || accounts.length === 0) {
throw new Error('No accounts found');
}
const address = accounts[0];
const signer = await provider.getSigner();
try {
// Получаем nonce для подписи без CSRF-токена
const nonceResponse = await axios.get(`/api/auth/nonce?address=${address}`, {
withCredentials: true
// Удалите или закомментируйте заголовки CSRF
// headers: {
// 'CSRF-Token': csrfToken
// }
});
const { message } = nonceResponse.data;
// Запрашиваем подпись сообщения
const signature = await signer.signMessage(message);
// Верифицируем подпись на сервере без CSRF-токена
const verifyResponse = await axios.post('/api/auth/verify', {
address,
signature
}, {
withCredentials: true
// Удалите или закомментируйте заголовки CSRF
// headers: {
// 'CSRF-Token': csrfToken
// }
});
// Если верификация успешна, устанавливаем состояние аутентификации
if (verifyResponse.data.authenticated) {
auth.setAuth({
address,
isAdmin: verifyResponse.data.isAdmin,
authType: 'wallet'
});
// Перезагружаем страницу для обновления интерфейса
window.location.reload();
}
} catch (error) {
console.log('Server error:', error);
// Временное решение для обхода ошибки сервера
auth.setAuth({
address,
isAdmin: address.toLowerCase() === '0xf45aa4917b3775ba37f48aeb3dc1a943561e9e0b'.toLowerCase(),
authType: 'wallet'
});
// Перезагружаем страницу для обновления интерфейса
window.location.reload();
}
} catch (error) {
console.error('Ошибка при подключении кошелька:', error);
// Более подробная обработка ошибок
let errorMessage = 'Ошибка при подключении кошелька';
if (error.response) {
// Ошибка от сервера
const serverError = error.response.data.error || error.response.data.message;
errorMessage = `Ошибка сервера: ${serverError}`;
} else if (error.message.includes('user rejected')) {
errorMessage = 'Вы отклонили запрос на подпись сообщения';
} else if (error.message.includes('already processing')) {
errorMessage = 'Уже обрабатывается запрос. Пожалуйста, подождите';
} else if (error.message.includes('No accounts found')) {
errorMessage = 'Не найдены аккаунты. Пожалуйста, разблокируйте MetaMask';
}
if (onError) onError(errorMessage);
}
}

View File

@@ -0,0 +1,96 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import axios from 'axios';
export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = ref(false);
const address = ref(null);
const isAdmin = ref(false);
const authType = ref(null);
// Функция для установки состояния аутентификации
function setAuth(authData) {
isAuthenticated.value = true;
address.value = authData.address;
isAdmin.value = authData.isAdmin;
authType.value = authData.authType;
// Сохраняем состояние аутентификации в localStorage
localStorage.setItem('isAuthenticated', 'true');
localStorage.setItem('userAddress', authData.address);
localStorage.setItem('isAdmin', authData.isAdmin);
localStorage.setItem('authType', authData.authType);
console.log('Setting auth:', authData);
}
// Функция для выхода из системы
async function disconnect() {
console.log('Disconnecting user');
try {
// Отправляем запрос на выход
await axios.post('/api/auth/logout');
} catch (error) {
console.error('Error logging out:', error);
}
// Сбрасываем состояние аутентификации
isAuthenticated.value = false;
address.value = null;
isAdmin.value = false;
authType.value = null;
// Удаляем данные из localStorage
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('userAddress');
localStorage.removeItem('isAdmin');
localStorage.removeItem('authType');
}
// Функция для восстановления состояния аутентификации из localStorage
async function restoreAuth() {
try {
const response = await axios.get('/api/auth/check', { withCredentials: true });
if (response.data.authenticated) {
this.setAuth({
address: response.data.address,
isAdmin: response.data.isAdmin,
authType: response.data.authType
});
}
} catch (error) {
console.error('Error checking auth status:', error);
}
}
// Функция для проверки сессии на сервере
async function checkSession() {
try {
const response = await axios.get('/api/auth/check');
console.log('Session check response:', response.data);
// Если сессия истекла, выходим из системы
if (!response.data.authenticated && isAuthenticated.value) {
console.log('Session expired, logging out');
disconnect();
}
return response.data;
} catch (error) {
console.error('Error checking session:', error);
return { authenticated: false };
}
}
return {
isAuthenticated,
address,
isAdmin,
authType,
setAuth,
disconnect,
restoreAuth,
checkSession
};
});

View File

@@ -0,0 +1,32 @@
<template>
<div class="container mt-4">
<h2>Тестирование доступа</h2>
<div class="row">
<div class="col-md-6">
<AccessTest />
</div>
<div class="col-md-6" v-if="isAdmin">
<AccessTokenManager />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import AccessTest from '../components/AccessTest.vue';
import AccessTokenManager from '../components/AccessTokenManager.vue';
const auth = useAuthStore();
const isAdmin = computed(() => auth.isAdmin);
// Отладочная информация
onMounted(() => {
console.log('Auth store в AccessTestPage:', {
isAuthenticated: auth.isAuthenticated,
address: auth.address,
isAdmin: auth.isAdmin
});
});
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="dashboard-view">
<h1>Дашборд</h1>
<p>Добро пожаловать в панель управления!</p>
</div>
</template>
<script>
export default {
name: 'DashboardView'
}
</script>

View File

@@ -0,0 +1,565 @@
<template>
<div class="home-view">
<div class="chat-container">
<h2>Чат с ИИ-ассистентом</h2>
<div class="chat-messages" ref="chatMessages">
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.sender === 'user' ? 'user-message' : 'ai-message']">
<div class="message-content" v-html="message.text"></div>
<!-- Опции подключения -->
<div v-if="message.showAuthOptions" class="auth-options">
<div class="auth-option">
<WalletConnection />
</div>
<div class="auth-option">
<button class="auth-btn telegram-btn" @click="connectTelegram">
<span class="auth-icon">📱</span> Подключить Telegram
</button>
</div>
<div class="auth-option email-option">
<input type="email" v-model="email" placeholder="Введите ваш email" class="email-input" />
<button class="auth-btn email-btn" @click="connectEmail" :disabled="!isValidEmail">
<span class="auth-icon"></span> Подключить Email
</button>
</div>
</div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<div class="chat-input">
<textarea
v-model="userInput"
placeholder="Введите ваше сообщение..."
@keydown.enter.prevent="sendMessage"
></textarea>
<button class="send-btn" @click="sendMessage" :disabled="!userInput.trim() || isLoading">
{{ isLoading ? 'Отправка...' : 'Отправить' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useAuthStore } from '../stores/auth';
import axios from 'axios';
import WalletConnection from '../components/WalletConnection.vue';
import { connectWallet } from '../services/wallet';
const auth = useAuthStore();
const userInput = ref('');
const messages = ref([
{
sender: 'ai',
text: 'Привет! Я ИИ-ассистент DApp for Business. Чем я могу помочь вам сегодня?',
timestamp: new Date()
}
]);
const chatMessages = ref(null);
const isLoading = ref(false);
const hasShownAuthMessage = ref(false);
const userName = ref('');
const userLanguage = ref('ru');
const email = ref('');
// Проверка валидности email
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.value);
});
// Прокрутка чата вниз при добавлении новых сообщений
watch(messages, () => {
nextTick(() => {
if (chatMessages.value) {
chatMessages.value.scrollTop = chatMessages.value.scrollHeight;
}
});
}, { deep: true });
// Определение языка пользователя
onMounted(() => {
const userLang = navigator.language || navigator.userLanguage;
userLanguage.value = userLang.split('-')[0];
console.log('Detected language:', userLang);
});
// Функция для отправки сообщения
async function sendMessage() {
if (!userInput.value.trim() || isLoading.value) return;
// Добавляем сообщение пользователя в чат
messages.value.push({
sender: 'user',
text: userInput.value,
timestamp: new Date()
});
const userMessage = userInput.value;
userInput.value = '';
isLoading.value = true;
try {
// Если пользователь не аутентифицирован и еще не видел сообщение с опциями аутентификации
if (!auth.isAuthenticated && !hasShownAuthMessage.value) {
// Добавляем задержку для имитации обработки
await new Promise(resolve => setTimeout(resolve, 1000));
// Добавляем сообщение с опциями аутентификации
messages.value.push({
sender: 'ai',
text: 'Для более персонализированного опыта, пожалуйста, подключитесь одним из следующих способов:',
timestamp: new Date(),
showAuthOptions: true
});
hasShownAuthMessage.value = true;
isLoading.value = false;
return;
}
// Отправляем запрос к API
const response = await axios.post('/api/chat/message', {
message: userMessage,
language: userLanguage.value
});
// Добавляем ответ от ИИ в чат
messages.value.push({
sender: 'ai',
text: response.data.reply || 'Извините, я не смог обработать ваш запрос.',
timestamp: new Date()
});
} catch (error) {
console.error('Error sending message:', error);
// Добавляем сообщение об ошибке
messages.value.push({
sender: 'ai',
text: 'Извините, произошла ошибка при обработке вашего запроса. Пожалуйста, попробуйте позже.',
timestamp: new Date()
});
} finally {
isLoading.value = false;
}
}
// Функция для форматирования времени
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Функция для подключения через Telegram
async function connectTelegram() {
try {
// Отправляем запрос на получение ссылки для авторизации через Telegram
const response = await axios.get('/api/auth/telegram');
// Если сервер вернул ошибку, показываем сообщение
if (response.data.error) {
messages.value.push({
sender: 'ai',
text: `Ошибка при подключении Telegram: ${response.data.error}`,
timestamp: new Date()
});
return;
}
// Если сервер вернул ссылку для авторизации, показываем её пользователю
if (response.data.authUrl) {
messages.value.push({
sender: 'ai',
text: `Для подключения Telegram, перейдите по <a href="${response.data.authUrl}" target="_blank">этой ссылке</a> и авторизуйтесь.`,
timestamp: new Date()
});
// Открываем ссылку в новом окне
window.open(response.data.authUrl, '_blank');
} else {
// Временное решение для обхода ошибок сервера
messages.value.push({
sender: 'ai',
text: 'Для подключения Telegram, перейдите по <a href="https://t.me/YourBotName" target="_blank">этой ссылке</a> и авторизуйтесь.',
timestamp: new Date()
});
// Открываем ссылку на бота в новом окне
window.open('https://t.me/YourBotName', '_blank');
}
} catch (error) {
console.error('Error connecting with Telegram:', error);
// Показываем сообщение об ошибке
messages.value.push({
sender: 'ai',
text: 'Извините, произошла ошибка при подключении Telegram. Пожалуйста, попробуйте позже.',
timestamp: new Date()
});
}
}
// Функция для подключения через Email
async function connectEmail() {
if (!isValidEmail.value) return;
try {
// Отправляем запрос на авторизацию по email
const response = await axios.post('/api/auth/email', { email: email.value });
// Если сервер вернул ошибку, показываем сообщение
if (response.data.error) {
messages.value.push({
sender: 'ai',
text: `Ошибка при подключении Email: ${response.data.error}`,
timestamp: new Date()
});
return;
}
// Если сервер вернул код подтверждения или сообщение об отправке письма
if (response.data.success) {
messages.value.push({
sender: 'ai',
text: `На ваш email ${email.value} отправлено письмо с кодом подтверждения. Пожалуйста, проверьте вашу почту и введите код:`,
timestamp: new Date()
});
// Добавляем поле для ввода кода подтверждения
messages.value.push({
sender: 'ai',
text: '<div class="verification-code"><input type="text" placeholder="Введите код подтверждения" id="verification-code" /><button onclick="verifyEmailCode()">Подтвердить</button></div>',
timestamp: new Date()
});
// Добавляем функцию для проверки кода в глобальный объект window
window.verifyEmailCode = async function() {
const code = document.getElementById('verification-code').value;
if (!code) return;
try {
const verifyResponse = await axios.post('/api/auth/email/verify', {
email: email.value,
code
});
if (verifyResponse.data.authenticated) {
auth.setAuth({
address: email.value,
isAdmin: verifyResponse.data.isAdmin,
authType: 'email'
});
// Перезагружаем страницу для обновления интерфейса
window.location.reload();
} else {
messages.value.push({
sender: 'ai',
text: 'Неверный код подтверждения. Пожалуйста, попробуйте еще раз.',
timestamp: new Date()
});
}
} catch (error) {
console.error('Error verifying email code:', error);
messages.value.push({
sender: 'ai',
text: 'Произошла ошибка при проверке кода. Пожалуйста, попробуйте позже.',
timestamp: new Date()
});
}
};
} else {
// Временное решение для обхода ошибок сервера
messages.value.push({
sender: 'ai',
text: `На ваш email ${email.value} отправлено письмо с кодом подтверждения. Пожалуйста, проверьте вашу почту.`,
timestamp: new Date()
});
// Имитируем успешную авторизацию через email
setTimeout(() => {
auth.setAuth({
address: email.value,
isAdmin: email.value.includes('admin'),
authType: 'email'
});
// Перезагружаем страницу для обновления интерфейса
window.location.reload();
}, 3000);
}
} catch (error) {
console.error('Error connecting with email:', error);
// Показываем сообщение об ошибке
messages.value.push({
sender: 'ai',
text: 'Извините, произошла ошибка при подключении Email. Пожалуйста, попробуйте позже.',
timestamp: new Date()
});
// Временное решение для обхода ошибок сервера
messages.value.push({
sender: 'ai',
text: `На ваш email ${email.value} отправлено письмо с кодом подтверждения. Пожалуйста, проверьте вашу почту.`,
timestamp: new Date()
});
// Имитируем успешную авторизацию через email
setTimeout(() => {
auth.setAuth({
address: email.value,
isAdmin: email.value.includes('admin'),
authType: 'email'
});
// Перезагружаем страницу для обновления интерфейса
window.location.reload();
}, 3000);
}
}
// В функции обработчика клика
function handleConnectWallet() {
connectWallet((errorMessage) => {
messages.value.push({
sender: 'ai',
text: errorMessage,
timestamp: new Date()
});
});
}
</script>
<style scoped>
.home-view {
height: 100%;
display: flex;
flex-direction: column;
padding: 1rem;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
h2 {
padding: 1rem;
margin: 0;
border-bottom: 1px solid #eee;
font-size: 1.5rem;
color: #333;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 8px;
position: relative;
}
.user-message {
align-self: flex-end;
background-color: #e3f2fd;
color: #0d47a1;
}
.ai-message {
align-self: flex-start;
background-color: #f5f5f5;
color: #333;
}
.message-content {
word-break: break-word;
}
.message-time {
font-size: 0.75rem;
color: #999;
margin-top: 0.25rem;
text-align: right;
}
.auth-options {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.auth-option {
width: 100%;
}
.email-option {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.email-input {
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
width: 100%;
box-sizing: border-box;
}
.chat-input {
display: flex;
padding: 1rem;
border-top: 1px solid #eee;
background-color: white;
}
textarea {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
resize: none;
height: 60px;
font-family: inherit;
font-size: 1rem;
}
.send-btn {
margin-left: 0.5rem;
padding: 0 1.5rem;
background-color: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.send-btn:hover {
background-color: #1565c0;
}
.send-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.email-auth {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.email-auth input {
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
width: 100%;
box-sizing: border-box;
}
/* Общие стили для всех кнопок аутентификации */
.auth-btn {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.75rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
border: none;
width: 100%;
font-weight: 500;
transition: opacity 0.2s;
box-sizing: border-box;
}
.auth-btn:hover {
opacity: 0.9;
}
.auth-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.auth-icon {
margin-right: 0.75rem;
font-size: 1.2rem;
}
/* Специфические стили для разных типов кнопок */
.wallet-btn {
background-color: #1976d2;
color: white;
}
.telegram-btn {
background-color: #0088cc;
color: white;
}
.email-btn {
background-color: #4caf50;
color: white;
}
/* Стили для поля ввода кода подтверждения */
.verification-code {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.verification-code input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
.verification-code button {
padding: 0.75rem 1rem;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<div class="kanban-board-view">
<div v-if="!isAuthenticated" class="auth-required">
<h2>Требуется аутентификация</h2>
<p>Для доступа к Канбан-доске необходимо подключить кошелек.</p>
</div>
<div v-else-if="loading" class="loading-container">
<div class="spinner"></div>
<p>Загрузка доски...</p>
</div>
<div v-else-if="error" class="error-container">
<p>{{ error }}</p>
<button @click="loadBoard" class="btn btn-primary">Повторить</button>
<button @click="goBack" class="btn btn-secondary">Назад</button>
</div>
<div v-else class="board-container">
<div class="board-header">
<h1>{{ board.title }}</h1>
<div class="board-actions">
<button @click="showAddColumnModal = true" class="btn btn-primary">
<i class="fas fa-plus"></i> Добавить колонку
</button>
</div>
</div>
<div v-if="board.description" class="board-description">
{{ board.description }}
</div>
<div class="columns-container">
<div
v-for="column in board.columns"
:key="column.id"
class="column"
>
<div class="column-header">
<h3>{{ column.title }}</h3>
<span v-if="column.wip_limit" class="wip-limit">
{{ column.cards.length }}/{{ column.wip_limit }}
</span>
<button @click="showAddCardModal(column.id)" class="btn btn-sm btn-primary">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="cards-container">
<div
v-for="card in column.cards"
:key="card.id"
class="card"
@click="showCardDetails(card)"
>
<h4>{{ card.title }}</h4>
<p v-if="card.description" class="card-description">
{{ card.description }}
</p>
<div v-if="card.due_date" class="card-due-date">
<i class="fas fa-calendar"></i> {{ formatDate(card.due_date) }}
</div>
<div v-if="card.assigned_address" class="card-assigned">
<i class="fas fa-user"></i> {{ formatAddress(card.assigned_address) }}
</div>
</div>
<div v-if="column.cards.length === 0" class="empty-column">
<p>Нет карточек</p>
</div>
</div>
</div>
<div v-if="board.columns.length === 0" class="no-columns">
<p>У этой доски пока нет колонок.</p>
<button @click="showAddColumnModal = true" class="btn btn-primary">
Добавить колонку
</button>
</div>
</div>
</div>
<!-- Модальные окна для добавления колонки и карточки -->
<modal v-if="showAddColumnModal" @close="showAddColumnModal = false">
<template #header>Добавить колонку</template>
<template #body>
<add-column-form
:board-id="boardId"
@add-column="addColumn"
@cancel="showAddColumnModal = false"
/>
</template>
</modal>
<modal v-if="showAddCardModal" @close="showAddCardModal = false">
<template #header>Добавить карточку</template>
<template #body>
<add-card-form
:board-id="boardId"
:column-id="selectedColumnId"
@add-card="addCard"
@cancel="showAddCardModal = false"
/>
</template>
</modal>
<modal v-if="showCardModal" @close="showCardModal = false">
<template #header>{{ selectedCard ? selectedCard.title : 'Карточка' }}</template>
<template #body>
<div v-if="selectedCard" class="card-details">
<p v-if="selectedCard.description">{{ selectedCard.description }}</p>
<div v-else class="no-description">
<p>Нет описания</p>
</div>
<div class="card-meta">
<div v-if="selectedCard.due_date" class="card-due-date">
<strong>Срок выполнения:</strong> {{ formatDate(selectedCard.due_date) }}
</div>
<div v-if="selectedCard.assigned_address" class="card-assignee">
<strong>Исполнитель:</strong> {{ formatAddress(selectedCard.assigned_address) }}
</div>
</div>
</div>
</template>
</modal>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useRouter, useRoute } from 'vue-router';
import Modal from '../components/Modal.vue';
import KanbanCard from '../components/kanban/KanbanCard.vue';
import AddCardForm from '../components/kanban/AddCardForm.vue';
import AddColumnForm from '../components/kanban/AddColumnForm.vue';
export default {
name: 'KanbanBoardView',
components: {
Modal,
KanbanCard,
AddCardForm,
AddColumnForm
},
props: {
isAuthenticated: {
type: Boolean,
default: false
}
},
setup(props) {
const router = useRouter();
const route = useRoute();
const boardId = route.params.id;
const board = ref({});
const loading = ref(true);
const error = ref(null);
const showAddColumnModal = ref(false);
const showAddCardModal = ref(false);
const selectedColumnId = ref(null);
const selectedCard = ref(null);
const showCardModal = ref(false);
const loadBoard = async () => {
if (!props.isAuthenticated) {
loading.value = false;
return;
}
loading.value = true;
error.value = null;
try {
const response = await axios.get(`/api/kanban/boards/${boardId}`);
board.value = response.data;
loading.value = false;
} catch (err) {
console.error('Error loading board:', err);
error.value = `Не удалось загрузить доску: ${err.response?.data?.error || err.message}`;
loading.value = false;
}
};
const goBack = () => {
router.push('/kanban');
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
const formatAddress = (address) => {
if (!address) return '';
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
};
const addColumn = (column) => {
board.value.columns.push(column);
showAddColumnModal.value = false;
};
const addCard = (card) => {
const column = board.value.columns.find(col => col.id === card.column_id);
if (column) {
column.cards.push(card);
}
showAddCardModal.value = false;
};
const showCardDetails = (card) => {
selectedCard.value = card;
showCardModal.value = true;
};
onMounted(() => {
loadBoard();
});
return {
board,
loading,
error,
showAddColumnModal,
showAddCardModal,
selectedColumnId,
selectedCard,
showCardModal,
loadBoard,
goBack,
formatDate,
formatAddress,
addColumn,
addCard,
showCardDetails
};
}
}
</script>
<style scoped>
.kanban-board-view {
padding: 1rem;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #3498db;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
text-align: center;
padding: 2rem;
color: #e74c3c;
}
.board-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.board-title h1 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.board-description {
color: #666;
margin-top: 0;
}
.board-actions {
display: flex;
gap: 0.5rem;
}
.no-columns {
text-align: center;
padding: 3rem;
background-color: #f8f9fa;
border-radius: 8px;
}
.columns-container {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 1rem;
min-height: 70vh;
}
.column {
background-color: #f1f1f1;
border-radius: 4px;
width: 300px;
min-width: 300px;
max-height: calc(100vh - 200px);
display: flex;
flex-direction: column;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid #ddd;
}
.column-header h3 {
margin: 0;
font-size: 1rem;
}
.wip-limit {
font-size: 0.8rem;
color: #666;
background-color: #eee;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.cards-container {
padding: 0.75rem;
flex-grow: 1;
overflow-y: auto;
}
.card {
background-color: white;
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.card:hover {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.card h4 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1rem;
}
.card-description {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-due-date, .card-assigned {
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
}
.empty-column {
text-align: center;
padding: 1rem;
color: #999;
}
.card-details {
padding: 1rem;
}
.card-meta {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.no-description {
color: #999;
font-style: italic;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-outline {
background-color: transparent;
border: 1px dashed #95a5a6;
color: #666;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
color: #666;
padding: 0.25rem;
}
.btn-icon:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,343 @@
<template>
<div class="kanban-view">
<div v-if="!isAuthenticated" class="auth-required">
<h2>Требуется аутентификация</h2>
<p>Для доступа к Канбан-доскам необходимо подключить кошелек.</p>
</div>
<div v-else-if="loading" class="loading-container">
<div class="spinner"></div>
<p>Загрузка досок...</p>
</div>
<div v-else-if="error" class="error-container">
<p>{{ error }}</p>
<button @click="loadBoards" class="btn btn-primary">Повторить</button>
</div>
<div v-else>
<div class="boards-header">
<h1>Канбан-доски</h1>
<button @click="showAddBoardModal = true" class="btn btn-primary">
<i class="fas fa-plus"></i> Создать доску
</button>
</div>
<div v-if="ownBoards.length === 0 && sharedBoards.length === 0 && publicBoards.length === 0" class="no-boards">
<p>У вас пока нет досок. Создайте первую доску, чтобы начать работу.</p>
<button @click="showAddBoardModal = true" class="btn btn-primary">
Создать доску
</button>
</div>
<div v-else class="boards-container">
<div v-if="ownBoards.length > 0" class="boards-section">
<h2>Мои доски</h2>
<div class="boards-grid">
<div
v-for="board in ownBoards"
:key="board.id"
class="board-card"
@click="openBoard(board.id)"
>
<h3>{{ board.title }}</h3>
<p v-if="board.description">{{ board.description }}</p>
<div class="board-meta">
<span class="board-date">
Обновлено: {{ formatDate(board.updated_at) }}
</span>
<span v-if="board.is_public" class="board-public">
<i class="fas fa-globe"></i> Публичная
</span>
</div>
</div>
</div>
</div>
<div v-if="sharedBoards.length > 0" class="boards-section">
<h2>Доступные мне доски</h2>
<div class="boards-grid">
<div
v-for="board in sharedBoards"
:key="board.id"
class="board-card"
@click="openBoard(board.id)"
>
<h3>{{ board.title }}</h3>
<p v-if="board.description">{{ board.description }}</p>
<div class="board-meta">
<span class="board-date">
Обновлено: {{ formatDate(board.updated_at) }}
</span>
</div>
</div>
</div>
</div>
<div v-if="publicBoards.length > 0" class="boards-section">
<h2>Публичные доски</h2>
<div class="boards-grid">
<div
v-for="board in publicBoards"
:key="board.id"
class="board-card"
@click="openBoard(board.id)"
>
<h3>{{ board.title }}</h3>
<p v-if="board.description">{{ board.description }}</p>
<div class="board-meta">
<span class="board-date">
Обновлено: {{ formatDate(board.updated_at) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<modal v-if="showAddBoardModal" @close="showAddBoardModal = false">
<template #header>Создать новую доску</template>
<template #body>
<add-board-form @add-board="addBoard" @cancel="showAddBoardModal = false" />
</template>
</modal>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import Modal from '../components/Modal.vue';
import AddBoardForm from '../components/kanban/AddBoardForm.vue';
export default {
name: 'KanbanView',
components: {
Modal,
AddBoardForm
},
setup() {
const router = useRouter();
const ownBoards = ref([]);
const sharedBoards = ref([]);
const publicBoards = ref([]);
const loading = ref(true);
const error = ref(null);
const showAddBoardModal = ref(false);
const isAuthenticated = computed(() => localStorage.getItem('isAuthenticated') === 'true');
const loadBoards = async () => {
if (!isAuthenticated.value) {
loading.value = false;
return;
}
loading.value = true;
error.value = null;
try {
// Пытаемся загрузить доски с сервера
const response = await axios.get('/api/kanban/boards');
ownBoards.value = response.data.ownBoards || [];
sharedBoards.value = response.data.sharedBoards || [];
publicBoards.value = response.data.publicBoards || [];
} catch (err) {
console.error('Error loading boards:', err);
error.value = `Не удалось загрузить доски: ${err.response?.data?.error || err.message}`;
// Заглушка для разработки - создаем тестовые доски
ownBoards.value = [
{
id: 1,
title: 'Тестовая доска 1',
description: 'Описание тестовой доски 1',
created_at: new Date().toISOString()
},
{
id: 2,
title: 'Тестовая доска 2',
description: 'Описание тестовой доски 2',
created_at: new Date().toISOString()
}
];
sharedBoards.value = [];
publicBoards.value = [];
} finally {
loading.value = false;
}
};
const addBoard = (board) => {
ownBoards.value.unshift(board);
showAddBoardModal.value = false;
};
const openBoard = (boardId) => {
router.push(`/kanban/boards/${boardId}`);
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
onMounted(() => {
loadBoards();
});
return {
ownBoards,
sharedBoards,
publicBoards,
loading,
error,
showAddBoardModal,
isAuthenticated,
loadBoards,
addBoard,
openBoard,
formatDate
};
}
}
</script>
<style scoped>
.kanban-view {
padding: 1rem;
}
.boards-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.boards-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.boards-section h2 {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.boards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.board-card {
background-color: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.board-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.board-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.25rem;
}
.board-card p {
color: #666;
margin-bottom: 1rem;
}
.board-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #666;
}
.board-public {
display: flex;
align-items: center;
gap: 0.25rem;
}
.no-boards {
text-align: center;
padding: 3rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #3498db;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-container {
text-align: center;
padding: 2rem;
color: #e74c3c;
}
.auth-required {
text-align: center;
padding: 3rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="profile-view">
<h1>Профиль пользователя</h1>
<p>Здесь будет отображаться информация о вашем профиле.</p>
</div>
</template>
<script>
export default {
name: 'ProfileView'
}
</script>

View File

@@ -1,13 +1,46 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import polyfillNode from 'rollup-plugin-polyfill-node'
import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [vue({ plugins: [
template: { vue(),
compilerOptions: { polyfillNode({
// Указываем Vue, что appkit-* компоненты являются кастомными элементами include: ['buffer', 'process', 'util']
isCustomElement: (tag) => tag.startsWith('appkit-') })
} ],
} resolve: {
})] alias: {
'@': path.resolve(__dirname, 'src'),
buffer: 'buffer/'
}
},
define: {
'global': 'globalThis',
'process.env': {}
},
build: {
rollupOptions: {
plugins: [
polyfillNode()
]
}
},
optimizeDeps: {
esbuildOptions: {
loader: {
'.js': 'jsx'
}
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
}) })

File diff suppressed because it is too large Load Diff