Описание изменений
This commit is contained in:
4
frontend/.vscode/settings.json
vendored
4
frontend/.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"typescript.validate.enable": false,
|
|
||||||
"javascript.validate.enable": true
|
|
||||||
}
|
|
||||||
@@ -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
2064
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
frontend/public/favicon.ico
Normal file
0
frontend/public/favicon.ico
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
81
frontend/src/components/AccessControl.vue
Normal file
81
frontend/src/components/AccessControl.vue
Normal 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>
|
||||||
194
frontend/src/components/AccessTest.vue
Normal file
194
frontend/src/components/AccessTest.vue
Normal 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>
|
||||||
209
frontend/src/components/AccessTokenManager.vue
Normal file
209
frontend/src/components/AccessTokenManager.vue
Normal 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>
|
||||||
298
frontend/src/components/Chats.vue
Normal file
298
frontend/src/components/Chats.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
193
frontend/src/components/LinkedAccounts.vue
Normal file
193
frontend/src/components/LinkedAccounts.vue
Normal 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>
|
||||||
82
frontend/src/components/Modal.vue
Normal file
82
frontend/src/components/Modal.vue
Normal 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')">×</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>
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
128
frontend/src/components/kanban/AddBoardForm.vue
Normal file
128
frontend/src/components/kanban/AddBoardForm.vue
Normal 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>
|
||||||
121
frontend/src/components/kanban/AddCardForm.vue
Normal file
121
frontend/src/components/kanban/AddCardForm.vue
Normal 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>
|
||||||
115
frontend/src/components/kanban/AddColumnForm.vue
Normal file
115
frontend/src/components/kanban/AddColumnForm.vue
Normal 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>
|
||||||
529
frontend/src/components/kanban/KanbanBoard.vue
Normal file
529
frontend/src/components/kanban/KanbanBoard.vue
Normal 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>
|
||||||
190
frontend/src/components/kanban/KanbanCard.vue
Normal file
190
frontend/src/components/kanban/KanbanCard.vue
Normal 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>
|
||||||
151
frontend/src/components/useEthereum.js
Normal file
151
frontend/src/components/useEthereum.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
69
frontend/src/mocks/api.js
Normal 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 настроены для режима разработки');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend/src/mocks/authApi.js
Normal file
68
frontend/src/mocks/authApi.js
Normal 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;
|
||||||
40
frontend/src/mocks/chatApi.js
Normal file
40
frontend/src/mocks/chatApi.js
Normal 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;
|
||||||
46
frontend/src/mocks/contractApi.js
Normal file
46
frontend/src/mocks/contractApi.js
Normal 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;
|
||||||
96
frontend/src/mocks/kanbanApi.js
Normal file
96
frontend/src/mocks/kanbanApi.js
Normal 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;
|
||||||
@@ -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;
|
||||||
106
frontend/src/services/wallet.js
Normal file
106
frontend/src/services/wallet.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/src/stores/auth.js
Normal file
96
frontend/src/stores/auth.js
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
32
frontend/src/views/AccessTestPage.vue
Normal file
32
frontend/src/views/AccessTestPage.vue
Normal 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>
|
||||||
12
frontend/src/views/DashboardView.vue
Normal file
12
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-view">
|
||||||
|
<h1>Дашборд</h1>
|
||||||
|
<p>Добро пожаловать в панель управления!</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'DashboardView'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
565
frontend/src/views/HomeView.vue
Normal file
565
frontend/src/views/HomeView.vue
Normal 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>
|
||||||
451
frontend/src/views/KanbanBoardView.vue
Normal file
451
frontend/src/views/KanbanBoardView.vue
Normal 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>
|
||||||
343
frontend/src/views/KanbanView.vue
Normal file
343
frontend/src/views/KanbanView.vue
Normal 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>
|
||||||
12
frontend/src/views/ProfileView.vue
Normal file
12
frontend/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-view">
|
||||||
|
<h1>Профиль пользователя</h1>
|
||||||
|
<p>Здесь будет отображаться информация о вашем профиле.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ProfileView'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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
Reference in New Issue
Block a user