Тестовый коммит после удаления husky
This commit is contained in:
@@ -1,40 +1,42 @@
|
||||
<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">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ 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 v-else-if="loading" class="loading-message">Загрузка...</div>
|
||||
<div v-else>
|
||||
<div v-if="!isConnected" class="alert alert-warning">
|
||||
Подключите ваш кошелек для проверки доступа
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useEthereum } from '../composables/useEthereum';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const { address, isConnected } = useEthereum();
|
||||
const authStore = useAuthStore();
|
||||
const address = ref('');
|
||||
const isConnected = ref(true);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const accessInfo = ref({
|
||||
hasAccess: false,
|
||||
token: '',
|
||||
role: '',
|
||||
expiresAt: null
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
// Форматирование даты
|
||||
@@ -46,17 +48,17 @@ function formatDate(timestamp) {
|
||||
// Проверка доступа
|
||||
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
|
||||
}
|
||||
'x-wallet-address': address.value,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
accessInfo.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Ошибка проверки доступа:', err);
|
||||
@@ -68,9 +70,12 @@ async function checkAccess() {
|
||||
}
|
||||
|
||||
// Проверяем доступ при изменении адреса
|
||||
watch(() => address.value, () => {
|
||||
checkAccess();
|
||||
});
|
||||
watch(
|
||||
() => address.value,
|
||||
() => {
|
||||
checkAccess();
|
||||
}
|
||||
);
|
||||
|
||||
// Проверяем доступ при монтировании компонента
|
||||
onMounted(() => {
|
||||
@@ -78,4 +83,106 @@ onMounted(() => {
|
||||
checkAccess();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
async function loadTokens() {
|
||||
try {
|
||||
console.log('Загрузка токенов...');
|
||||
loading.value = true;
|
||||
|
||||
// Добавляем withCredentials для передачи куки с сессией
|
||||
const response = await axios.get('/api/access/tokens', {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
console.log('Ответ API:', response.data);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
// Если есть токены, берем первый активный
|
||||
const activeToken = response.data.find((token) => {
|
||||
const expiresAt = new Date(token.expires_at);
|
||||
return expiresAt > new Date();
|
||||
});
|
||||
|
||||
if (activeToken) {
|
||||
accessInfo.value = {
|
||||
hasAccess: true,
|
||||
token: activeToken.id,
|
||||
role: activeToken.role,
|
||||
expiresAt: activeToken.expires_at,
|
||||
};
|
||||
} else {
|
||||
accessInfo.value = { hasAccess: false };
|
||||
}
|
||||
} else {
|
||||
accessInfo.value = { hasAccess: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке токенов:', error);
|
||||
error.value = 'Ошибка при проверке доступа: ' + (error.response?.data?.error || error.message);
|
||||
accessInfo.value = { hasAccess: false };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('Компонент AccessControl загружен');
|
||||
console.log('isAdmin:', authStore.isAdmin);
|
||||
await loadTokens();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-control {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: #0c5460;
|
||||
background-color: #d1ecf1;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<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>
|
||||
@@ -1,209 +1,161 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Управление токенами доступа</h5>
|
||||
<div class="access-token-manager">
|
||||
<h3>Управление токенами доступа</h3>
|
||||
<div class="token-actions">
|
||||
<button @click="mintNewToken">Выпустить новый токен</button>
|
||||
<button @click="loadTokens">Обновить список</button>
|
||||
</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 v-if="loading">Загрузка...</div>
|
||||
|
||||
<table v-else-if="tokens.length > 0" class="tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Владелец</th>
|
||||
<th>Роль</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in tokens" :key="token.id">
|
||||
<td>{{ token.id }}</td>
|
||||
<td>{{ token.owner }}</td>
|
||||
<td>{{ getRoleName(token.role) }}</td>
|
||||
<td>
|
||||
<button @click="revokeToken(token.id)">Отозвать</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else>Нет доступных токенов</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
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
// Сокращение адреса кошелька
|
||||
function shortenAddress(addr) {
|
||||
if (!addr) return '';
|
||||
return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
|
||||
const roles = {
|
||||
0: 'Администратор',
|
||||
1: 'Модератор',
|
||||
2: 'Пользователь',
|
||||
};
|
||||
|
||||
function getRoleName(roleId) {
|
||||
return roles[roleId] || 'Неизвестная роль';
|
||||
}
|
||||
|
||||
// Форматирование даты
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'Н/Д';
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
// Загрузка токенов
|
||||
async function loadTokens() {
|
||||
if (!isConnected.value || !address.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
console.log('Загрузка токенов...');
|
||||
loading.value = true;
|
||||
|
||||
// Добавляем withCredentials для передачи куки с сессией
|
||||
const response = await axios.get('/api/access/tokens', {
|
||||
headers: {
|
||||
'x-wallet-address': address.value
|
||||
}
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
console.log('Ответ API:', response.data);
|
||||
tokens.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки токенов:', err);
|
||||
alert('Ошибка загрузки токенов: ' + (err.response?.data?.error || err.message));
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке токенов:', error);
|
||||
if (error.response) {
|
||||
console.error('Статус ошибки:', error.response.status);
|
||||
console.error('Данные ошибки:', error.response.data);
|
||||
} else if (error.request) {
|
||||
console.error('Запрос без ответа:', error.request);
|
||||
} else {
|
||||
console.error('Ошибка настройки запроса:', error.message);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Создание токена
|
||||
async function createToken() {
|
||||
if (!isConnected.value || !address.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
async function mintNewToken() {
|
||||
try {
|
||||
await axios.post('/api/access/tokens',
|
||||
const walletAddress = prompt('Введите адрес получателя:');
|
||||
if (!walletAddress) return;
|
||||
|
||||
const role = prompt('Введите роль (ADMIN, MODERATOR, USER):');
|
||||
if (!role) return;
|
||||
|
||||
const expiresInDays = prompt('Введите срок действия в днях:');
|
||||
if (!expiresInDays) return;
|
||||
|
||||
// Используем правильные имена параметров
|
||||
await axios.post(
|
||||
'/api/access/mint',
|
||||
{
|
||||
walletAddress: newToken.value.walletAddress,
|
||||
role: newToken.value.role,
|
||||
expiresInDays: parseInt(newToken.value.expiresInDays)
|
||||
walletAddress,
|
||||
role,
|
||||
expiresInDays,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-wallet-address': address.value
|
||||
}
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Сбрасываем форму
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при выпуске токена:', error);
|
||||
if (error.response) {
|
||||
console.error('Статус ошибки:', error.response.status);
|
||||
console.error('Данные ошибки:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отзыв токена
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
// Перезагружаем список токенов
|
||||
if (!confirm(`Вы уверены, что хотите отозвать токен #${tokenId}?`)) return;
|
||||
|
||||
await axios.post('/api/access/revoke', { tokenId });
|
||||
await loadTokens();
|
||||
|
||||
alert('Токен успешно отозван');
|
||||
} catch (err) {
|
||||
console.error('Ошибка отзыва токена:', err);
|
||||
alert('Ошибка отзыва токена: ' + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отзыве токена:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем токены при монтировании компонента
|
||||
onMounted(() => {
|
||||
if (isConnected.value && address.value) {
|
||||
loadTokens();
|
||||
}
|
||||
onMounted(async () => {
|
||||
await loadTokens();
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-token-manager {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.tokens-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.tokens-table th,
|
||||
.tokens-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tokens-table th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-right: 5px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<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,43 +1,41 @@
|
||||
<template>
|
||||
<div class="linked-accounts">
|
||||
<h2>Связанные аккаунты</h2>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Загрузка...
|
||||
</div>
|
||||
|
||||
|
||||
<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-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
|
||||
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>
|
||||
<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>
|
||||
@@ -48,80 +46,85 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
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;
|
||||
}
|
||||
const authStore = useAuthStore();
|
||||
const identities = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
const userAddress = computed(() => authStore.address);
|
||||
|
||||
// Получение связанных аккаунтов
|
||||
async function fetchLinkedAccounts() {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/identities/linked`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
identities.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Ошибка при получении связанных аккаунтов:', err);
|
||||
error.value = 'Не удалось загрузить связанные аккаунты. Попробуйте позже.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Отвязывание аккаунта
|
||||
async function unlinkAccount(identity) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${import.meta.env.VITE_API_URL}/api/identities/unlink`,
|
||||
{
|
||||
type: identity.identity_type,
|
||||
value: identity.identity_value,
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Обновляем список после отвязки
|
||||
await fetchLinkedAccounts();
|
||||
} catch (err) {
|
||||
console.error('Ошибка при отвязке аккаунта:', err);
|
||||
error.value = 'Не удалось отвязать аккаунт. Попробуйте позже.';
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование типа идентификатора
|
||||
function getIdentityTypeLabel(type) {
|
||||
const labels = {
|
||||
ethereum: 'Ethereum',
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
// Форматирование значения идентификатора
|
||||
function 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;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchLinkedAccounts();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -131,7 +134,9 @@ export default {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading, .error, .no-accounts {
|
||||
.loading,
|
||||
.error,
|
||||
.no-accounts {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
@@ -190,4 +195,4 @@ code {
|
||||
border-radius: 4px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -15,11 +15,27 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Modal',
|
||||
emits: ['close']
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
// Закрытие модального окна по нажатию Escape
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden'; // Блокируем прокрутку страницы
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = ''; // Восстанавливаем прокрутку страницы
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -43,17 +59,21 @@ export default {
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@@ -65,11 +85,7 @@ export default {
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@@ -79,4 +95,4 @@ export default {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
133
frontend/src/components/Navigation.vue
Normal file
133
frontend/src/components/Navigation.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<nav class="main-nav">
|
||||
<div class="nav-brand">
|
||||
<router-link to="/">DApp for Business</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<router-link to="/" class="nav-link">Главная</router-link>
|
||||
<router-link to="/chat" class="nav-link">Чат</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin" class="nav-link admin-link">
|
||||
Админ-панель
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-auth">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<div class="user-info">
|
||||
<span class="user-address">{{ formatAddress(authStore.address) }}</span>
|
||||
<span v-if="authStore.isAdmin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
<button @click="logout" class="btn-logout">Выйти</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<wallet-connection />
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import WalletConnection from './WalletConnection.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Форматирование адреса кошелька
|
||||
function formatAddress(address) {
|
||||
if (!address) return '';
|
||||
return address.substring(0, 6) + '...' + address.substring(address.length - 4);
|
||||
}
|
||||
|
||||
// Выход из системы
|
||||
async function logout() {
|
||||
await authStore.logout();
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-link.router-link-active {
|
||||
color: #3498db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.nav-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-address {
|
||||
font-family: monospace;
|
||||
background-color: #f0f0f0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
161
frontend/src/components/RoleManager.vue
Normal file
161
frontend/src/components/RoleManager.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="role-manager">
|
||||
<h2>Управление ролями пользователей</h2>
|
||||
|
||||
<div v-if="loading" class="loading">Загрузка...</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="current-role">
|
||||
<h3>Ваша роль: {{ currentRole }}</h3>
|
||||
<button @click="checkRole" :disabled="checkingRole">
|
||||
{{ checkingRole ? 'Проверка...' : 'Обновить роль' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="admin-section">
|
||||
<h3>Пользователи системы</h3>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Роль</th>
|
||||
<th>Язык</th>
|
||||
<th>Дата регистрации</th>
|
||||
<th>Последняя проверка токенов</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username || 'Не указано' }}</td>
|
||||
<td>{{ user.role || 'user' }}</td>
|
||||
<td>{{ user.preferred_language || 'ru' }}</td>
|
||||
<td>{{ formatDate(user.created_at) }}</td>
|
||||
<td>{{ user.last_token_check ? formatDate(user.last_token_check) : 'Не проверялся' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const users = ref([]);
|
||||
const currentRole = ref('user');
|
||||
const isAdmin = ref(false);
|
||||
const checkingRole = ref(false);
|
||||
|
||||
// Загрузка пользователей с ролями
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await axios.get('/api/roles/users');
|
||||
users.value = response.data;
|
||||
} catch (err) {
|
||||
console.error('Error loading users:', err);
|
||||
error.value = 'Ошибка при загрузке пользователей';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка роли текущего пользователя
|
||||
const checkRole = async () => {
|
||||
try {
|
||||
checkingRole.value = true;
|
||||
const response = await axios.post('/api/roles/check-role');
|
||||
isAdmin.value = response.data.isAdmin;
|
||||
currentRole.value = isAdmin.value ? 'admin' : 'user';
|
||||
|
||||
// Если пользователь стал администратором, загрузим список пользователей
|
||||
if (isAdmin.value) {
|
||||
await loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking role:', err);
|
||||
error.value = 'Ошибка при проверке роли';
|
||||
} finally {
|
||||
checkingRole.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// Проверяем роль при загрузке компонента
|
||||
await checkRole();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
users,
|
||||
currentRole,
|
||||
isAdmin,
|
||||
checkingRole,
|
||||
checkRole,
|
||||
formatDate
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-manager {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.current-role {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.users-table th, .users-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +1,98 @@
|
||||
<template>
|
||||
<button @click.stop.prevent="connect" class="auth-btn wallet-btn">
|
||||
<span class="auth-icon">💼</span> Подключить кошелек
|
||||
</button>
|
||||
<div class="wallet-connection">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button @click="connect" class="connect-button" :disabled="loading">
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
{{ loading ? 'Подключение...' : 'Подключить кошелек' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { connectWallet } from '../services/wallet';
|
||||
import { ref } from 'vue';
|
||||
import { connectWallet } from '../utils/wallet';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
function connect() {
|
||||
async function connect() {
|
||||
console.log('Нажата кнопка "Подключить кошелек"');
|
||||
connectWallet((errorMessage) => {
|
||||
error.value = errorMessage;
|
||||
console.error('Ошибка при подключении кошелька:', errorMessage);
|
||||
});
|
||||
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const authResult = await connectWallet();
|
||||
console.log('Результат подключения:', authResult);
|
||||
|
||||
if (authResult && authResult.authenticated) {
|
||||
authStore.updateAuthState(authResult);
|
||||
router.push({ name: 'home' });
|
||||
} else {
|
||||
error.value = 'Не удалось подключить кошелек';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при подключении кошелька:', error);
|
||||
error.value = error.message || 'Ошибка при подключении кошелька';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Стили для кнопки подключения кошелька */
|
||||
.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;
|
||||
.wallet-connection {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.auth-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.wallet-btn {
|
||||
.connect-button {
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
.connect-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d32f2f;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background-color: #ffebee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
225
frontend/src/components/chat/ConversationList.vue
Normal file
225
frontend/src/components/chat/ConversationList.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="conversation-list">
|
||||
<div class="list-header">
|
||||
<h3>Диалоги</h3>
|
||||
<button @click="createNewConversation" class="new-conversation-btn">
|
||||
<span>+</span> Новый диалог
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Загрузка диалогов...</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-list">
|
||||
<p>У вас пока нет диалогов.</p>
|
||||
<p>Создайте новый диалог, чтобы начать общение с ИИ-ассистентом.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations">
|
||||
<div
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.conversation_id"
|
||||
:class="[
|
||||
'conversation-item',
|
||||
{ active: selectedConversationId === conversation.conversation_id },
|
||||
]"
|
||||
@click="selectConversation(conversation.conversation_id)"
|
||||
>
|
||||
<div class="conversation-title">{{ conversation.title }}</div>
|
||||
<div class="conversation-meta">
|
||||
<span class="message-count">{{ conversation.message_count }} сообщений</span>
|
||||
<span class="time">{{ formatTime(conversation.last_activity) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, defineEmits } from 'vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import axios from 'axios';
|
||||
|
||||
const emit = defineEmits(['select-conversation']);
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const conversations = ref([]);
|
||||
const loading = ref(true);
|
||||
const selectedConversationId = ref(null);
|
||||
|
||||
// Загрузка списка диалогов
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await axios.get('/api/messages/conversations');
|
||||
conversations.value = response.data;
|
||||
|
||||
// Если есть диалоги и не выбран ни один, выбираем первый
|
||||
if (conversations.value.length > 0 && !selectedConversationId.value) {
|
||||
selectConversation(conversations.value[0].conversation_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversations:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Выбор диалога
|
||||
const selectConversation = (conversationId) => {
|
||||
selectedConversationId.value = conversationId;
|
||||
emit('select-conversation', conversationId);
|
||||
};
|
||||
|
||||
// Создание нового диалога
|
||||
const createNewConversation = async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/messages/conversations', {
|
||||
title: 'Новый диалог',
|
||||
});
|
||||
|
||||
// Добавляем новый диалог в список
|
||||
const newConversation = {
|
||||
conversation_id: response.data.id,
|
||||
title: response.data.title,
|
||||
username: authStore.username,
|
||||
address: authStore.address,
|
||||
message_count: 0,
|
||||
last_activity: response.data.created_at,
|
||||
created_at: response.data.created_at,
|
||||
};
|
||||
|
||||
conversations.value.unshift(newConversation);
|
||||
|
||||
// Выбираем новый диалог
|
||||
selectConversation(newConversation.conversation_id);
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
// Сегодня - показываем только время
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
// Вчера
|
||||
return 'Вчера';
|
||||
} else if (diffDays < 7) {
|
||||
// В течение недели - показываем день недели
|
||||
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
return days[date.getDay()];
|
||||
} else {
|
||||
// Более недели назад - показываем дату
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка диалогов при монтировании компонента
|
||||
onMounted(() => {
|
||||
fetchConversations();
|
||||
});
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
fetchConversations,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background-color: #f9f9f9;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.list-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.new-conversation-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-conversation-btn span {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-list {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-list p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.conversations {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background-color: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
134
frontend/src/components/chat/MessageInput.vue
Normal file
134
frontend/src/components/chat/MessageInput.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="message-input">
|
||||
<textarea
|
||||
v-model="message"
|
||||
placeholder="Введите сообщение..."
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
ref="textareaRef"
|
||||
:disabled="sending"
|
||||
></textarea>
|
||||
|
||||
<button @click="sendMessage" class="send-button" :disabled="!message.trim() || sending">
|
||||
<span v-if="sending">Отправка...</span>
|
||||
<span v-else>Отправить</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['message-sent']);
|
||||
const message = ref('');
|
||||
const sending = ref(false);
|
||||
const textareaRef = ref(null);
|
||||
|
||||
// Обработка нажатия Enter
|
||||
const handleEnter = (event) => {
|
||||
// Если нажат Shift+Enter, добавляем перенос строки
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе отправляем сообщение
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
// Отправка сообщения
|
||||
const sendMessage = async () => {
|
||||
if (!message.value.trim() || sending.value) return;
|
||||
|
||||
try {
|
||||
sending.value = true;
|
||||
|
||||
const response = await axios.post(
|
||||
`/api/messages/conversations/${props.conversationId}/messages`,
|
||||
{ content: message.value }
|
||||
);
|
||||
|
||||
// Очищаем поле ввода
|
||||
message.value = '';
|
||||
|
||||
// Фокусируемся на поле ввода
|
||||
nextTick(() => {
|
||||
textareaRef.value.focus();
|
||||
});
|
||||
|
||||
// Уведомляем родительский компонент о новых сообщениях
|
||||
emit('message-sent', [response.data.userMessage, response.data.aiMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Сброс поля ввода
|
||||
const resetInput = () => {
|
||||
message.value = '';
|
||||
};
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
resetInput,
|
||||
focus: () => textareaRef.value?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0 1rem;
|
||||
height: 40px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
background-color: #43a047;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
background-color: #9e9e9e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
194
frontend/src/components/chat/MessageThread.vue
Normal file
194
frontend/src/components/chat/MessageThread.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="message-thread" ref="threadContainer">
|
||||
<div v-if="loading" class="loading">Загрузка сообщений...</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="empty-thread">
|
||||
<p>Нет сообщений. Начните диалог, отправив сообщение.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="messages">
|
||||
<div v-for="message in messages" :key="message.id" :class="['message', message.sender_type]">
|
||||
<div class="message-content">{{ message.content }}</div>
|
||||
<div class="message-meta">
|
||||
<span class="time">{{ formatTime(message.created_at) }}</span>
|
||||
<span v-if="message.channel" class="channel">
|
||||
{{ channelName(message.channel) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick, defineExpose } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages = ref([]);
|
||||
const loading = ref(true);
|
||||
const threadContainer = ref(null);
|
||||
|
||||
// Загрузка сообщений диалога
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await axios.get(
|
||||
`/api/messages/conversations/${props.conversationId}/messages`
|
||||
);
|
||||
messages.value = response.data;
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Добавление новых сообщений
|
||||
const addMessages = (newMessages) => {
|
||||
if (Array.isArray(newMessages)) {
|
||||
messages.value = [...messages.value, ...newMessages];
|
||||
} else {
|
||||
messages.value.push(newMessages);
|
||||
}
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
const scrollToBottom = () => {
|
||||
if (threadContainer.value) {
|
||||
threadContainer.value.scrollTop = threadContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Получение названия канала
|
||||
const channelName = (channel) => {
|
||||
const channels = {
|
||||
web: 'Веб',
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
return channels[channel] || channel;
|
||||
};
|
||||
|
||||
// Наблюдение за изменением ID диалога
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
(newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
fetchMessages();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Загрузка сообщений при монтировании компонента
|
||||
onMounted(() => {
|
||||
if (props.conversationId) {
|
||||
fetchMessages();
|
||||
}
|
||||
});
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
fetchMessages,
|
||||
addMessages,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-thread {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
.message.ai {
|
||||
align-self: flex-start;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.message.admin {
|
||||
align-self: flex-start;
|
||||
background-color: #fff3e0;
|
||||
border: 1px dashed #ffb74d;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.channel {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
0
frontend/src/components/identity/EmailConnect.vue
Normal file
0
frontend/src/components/identity/EmailConnect.vue
Normal file
@@ -1,128 +0,0 @@
|
||||
<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>
|
||||
@@ -1,121 +0,0 @@
|
||||
<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>
|
||||
@@ -1,115 +0,0 @@
|
||||
<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>
|
||||
@@ -1,529 +0,0 @@
|
||||
<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>
|
||||
@@ -1,190 +0,0 @@
|
||||
<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>
|
||||
@@ -1,151 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user