Files
DLE/frontend/src/views/smartcontracts/HistoryView.vue

1058 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/HB3-ACCELERATOR
-->
<template>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div class="history-container">
<!-- Заголовок -->
<div class="page-header">
<div class="header-content">
<h1>История</h1>
<p>Лог операций, события и транзакции DLE</p>
</div>
<button class="close-btn" @click="router.push('/management')">×</button>
</div>
<!-- Фильтры -->
<div class="filters-section">
<h2>Фильтры</h2>
<div class="filters-form">
<div class="filters-row">
<div class="filter-group">
<label for="eventType">Тип события:</label>
<select id="eventType" v-model="filters.eventType">
<option value="">Все события</option>
<option value="proposal">Предложения</option>
<option value="vote">Голосования</option>
<option value="transfer">Трансферы</option>
<option value="treasury">Казна</option>
<option value="module">Модули</option>
<option value="settings">Настройки</option>
</select>
</div>
<div class="filter-group">
<label for="dateFrom">Дата с:</label>
<input
id="dateFrom"
v-model="filters.dateFrom"
type="date"
>
</div>
<div class="filter-group">
<label for="dateTo">Дата по:</label>
<input
id="dateTo"
v-model="filters.dateTo"
type="date"
>
</div>
<div class="filter-group">
<label for="status">Статус:</label>
<select id="status" v-model="filters.status">
<option value="">Все статусы</option>
<option value="success">Успешно</option>
<option value="pending">В обработке</option>
<option value="failed">Ошибка</option>
</select>
</div>
</div>
<div class="filters-actions">
<button @click="applyFilters" class="btn-primary">Применить фильтры</button>
<button @click="clearFilters" class="btn-secondary">Сбросить</button>
</div>
</div>
</div>
<!-- Статистика -->
<div class="stats-section">
<h2>Статистика</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Всего операций</h3>
<p class="stat-value">{{ totalOperations }}</p>
</div>
<div class="stat-card">
<h3>Успешных</h3>
<p class="stat-value success">{{ successfulOperations }}</p>
</div>
<div class="stat-card">
<h3>Ошибок</h3>
<p class="stat-value error">{{ failedOperations }}</p>
</div>
<div class="stat-card">
<h3>В обработке</h3>
<p class="stat-value pending">{{ pendingOperations }}</p>
</div>
</div>
</div>
<!-- История операций -->
<div class="history-section">
<h2>История операций</h2>
<div class="history-controls">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="Поиск по описанию или адресу..."
@input="filterHistory"
>
</div>
<div class="sort-controls">
<select v-model="sortBy" @change="sortHistory">
<option value="timestamp">По дате</option>
<option value="type">По типу</option>
<option value="status">По статусу</option>
</select>
<button @click="toggleSortOrder" class="sort-btn">
{{ sortOrder === 'desc' ? '↓' : '↑' }}
</button>
</div>
</div>
<div v-if="filteredHistory.length === 0" class="empty-state">
<p>Нет операций, соответствующих фильтрам</p>
</div>
<div v-else class="history-list">
<div
v-for="event in filteredHistory"
:key="event.id"
class="history-item"
:class="event.status"
>
<div class="event-icon">
<span class="icon">{{ getEventIcon(event.type) }}</span>
</div>
<div class="event-content">
<div class="event-header">
<h3>{{ getEventTitle(event) }}</h3>
<span class="event-status" :class="event.status">
{{ getStatusText(event.status) }}
</span>
</div>
<div class="event-details">
<p class="event-description">{{ event.description }}</p>
<div class="event-meta">
<span class="event-date">{{ formatDate(event.timestamp) }}</span>
<span class="event-hash">Tx: {{ formatHash(event.transactionHash) }}</span>
<span v-if="event.blockNumber" class="event-block">Block: {{ event.blockNumber }}</span>
</div>
<div v-if="event.data" class="event-data">
<div v-for="(value, key) in event.data" :key="key" class="data-item">
<span class="data-label">{{ key }}:</span>
<span class="data-value">{{ formatDataValue(value) }}</span>
</div>
</div>
</div>
</div>
<div class="event-actions">
<button @click="viewDetails(event)" class="btn-secondary">
Детали
</button>
<button @click="viewOnExplorer(event)" class="btn-secondary">
Explorer
</button>
</div>
</div>
</div>
<!-- Пагинация -->
<div v-if="totalPages > 1" class="pagination">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="page-btn"
>
</button>
<span class="page-info">
Страница {{ currentPage }} из {{ totalPages }}
</span>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="page-btn"
>
</button>
</div>
</div>
<!-- Модальное окно деталей -->
<div v-if="showDetailsModal" class="modal-overlay" @click="showDetailsModal = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>Детали операции</h3>
<button @click="showDetailsModal = false" class="close-btn"></button>
</div>
<div class="modal-body">
<div v-if="selectedEvent" class="event-details-full">
<div class="detail-row">
<span class="detail-label">Тип:</span>
<span class="detail-value">{{ getEventTitle(selectedEvent) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Статус:</span>
<span class="detail-value" :class="selectedEvent.status">
{{ getStatusText(selectedEvent.status) }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Дата:</span>
<span class="detail-value">{{ formatDate(selectedEvent.timestamp) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Транзакция:</span>
<span class="detail-value">{{ selectedEvent.transactionHash }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Блок:</span>
<span class="detail-value">{{ selectedEvent.blockNumber }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Описание:</span>
<span class="detail-value">{{ selectedEvent.description }}</span>
</div>
<div v-if="selectedEvent.data" class="detail-section">
<h4>Данные операции:</h4>
<div class="data-grid">
<div
v-for="(value, key) in selectedEvent.data"
:key="key"
class="data-item-full"
>
<span class="data-label">{{ key }}:</span>
<span class="data-value">{{ formatDataValue(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
// Определяем props
const props = defineProps({
isAuthenticated: Boolean,
identities: Array,
tokenBalances: Object,
isLoadingTokens: Boolean
});
// Определяем emits
const emit = defineEmits(['auth-action-completed']);
const router = useRouter();
// Состояние
const showDetailsModal = ref(false);
const selectedEvent = ref(null);
const searchQuery = ref('');
const sortBy = ref('timestamp');
const sortOrder = ref('desc');
const currentPage = ref(1);
const itemsPerPage = ref(20);
// Фильтры
const filters = ref({
eventType: '',
dateFrom: '',
dateTo: '',
status: ''
});
// История операций (временные данные)
const history = ref([
{
id: 1,
type: 'proposal',
title: 'Создание предложения',
description: 'Создано предложение #15: Перевод 100 токенов партнеру',
timestamp: Date.now() - 3600000,
status: 'success',
transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
blockNumber: 18456789,
data: {
'ID предложения': 15,
'Инициатор': '0x1234...5678',
'Количество токенов': '100 MDLE',
'Получатель': '0xabcd...efgh'
}
},
{
id: 2,
type: 'vote',
title: 'Голосование',
description: 'Подписано предложение #15',
timestamp: Date.now() - 7200000,
status: 'success',
transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
blockNumber: 18456788,
data: {
'ID предложения': 15,
'Голосующий': '0x5678...9012',
'Вес голоса': '500 токенов'
}
},
{
id: 3,
type: 'transfer',
title: 'Трансфер токенов',
description: 'Перевод 50 токенов между участниками',
timestamp: Date.now() - 10800000,
status: 'success',
transactionHash: '0x567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234',
blockNumber: 18456787,
data: {
'От': '0x9012...3456',
'Кому': '0x3456...7890',
'Количество': '50 MDLE'
}
},
{
id: 4,
type: 'treasury',
title: 'Операция с казной',
description: 'Пополнение казны на 1000 USDC',
timestamp: Date.now() - 14400000,
status: 'pending',
transactionHash: '0x901234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd',
blockNumber: 18456786,
data: {
'Тип операции': 'Депозит',
'Актив': 'USDC',
'Количество': '1000'
}
},
{
id: 5,
type: 'module',
title: 'Установка модуля',
description: 'Установлен модуль "Казначейство"',
timestamp: Date.now() - 18000000,
status: 'success',
transactionHash: '0x345678901234567890abcdef1234567890abcdef1234567890abcdef123456789',
blockNumber: 18456785,
data: {
'Название модуля': 'Казначейство',
'Версия': '1.0.0',
'Адрес контракта': '0xabcd...efgh'
}
}
]);
// Вычисляемые свойства
const filteredHistory = computed(() => {
let filtered = history.value;
// Фильтр по типу события
if (filters.value.eventType) {
filtered = filtered.filter(event => event.type === filters.value.eventType);
}
// Фильтр по статусу
if (filters.value.status) {
filtered = filtered.filter(event => event.status === filters.value.status);
}
// Фильтр по датам
if (filters.value.dateFrom) {
const fromDate = new Date(filters.value.dateFrom).getTime();
filtered = filtered.filter(event => event.timestamp >= fromDate);
}
if (filters.value.dateTo) {
const toDate = new Date(filters.value.dateTo).getTime();
filtered = filtered.filter(event => event.timestamp <= toDate);
}
// Поиск
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(event =>
event.description.toLowerCase().includes(query) ||
event.transactionHash.toLowerCase().includes(query)
);
}
// Сортировка
filtered.sort((a, b) => {
let aValue = a[sortBy.value];
let bValue = b[sortBy.value];
if (sortBy.value === 'timestamp') {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
}
if (sortOrder.value === 'desc') {
return bValue > aValue ? 1 : -1;
} else {
return aValue > bValue ? 1 : -1;
}
});
return filtered;
});
const totalOperations = computed(() => history.value.length);
const successfulOperations = computed(() => history.value.filter(e => e.status === 'success').length);
const failedOperations = computed(() => history.value.filter(e => e.status === 'failed').length);
const pendingOperations = computed(() => history.value.filter(e => e.status === 'pending').length);
const totalPages = computed(() => Math.ceil(filteredHistory.value.length / itemsPerPage.value));
// Методы
const applyFilters = () => {
currentPage.value = 1;
};
const clearFilters = () => {
filters.value = {
eventType: '',
dateFrom: '',
dateTo: '',
status: ''
};
searchQuery.value = '';
currentPage.value = 1;
};
const filterHistory = () => {
currentPage.value = 1;
};
const sortHistory = () => {
currentPage.value = 1;
};
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc';
currentPage.value = 1;
};
const changePage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
const getEventIcon = (type) => {
const icons = {
proposal: '📋',
vote: '🗳️',
transfer: '💸',
treasury: '🏦',
module: '🔧',
settings: '⚙️'
};
return icons[type] || '📄';
};
const getEventTitle = (event) => {
return event.title || 'Операция';
};
const getStatusText = (status) => {
const statusMap = {
success: 'Успешно',
pending: 'В обработке',
failed: 'Ошибка'
};
return statusMap[status] || status;
};
const formatDate = (timestamp) => {
return new Date(timestamp).toLocaleString('ru-RU');
};
const formatHash = (hash) => {
if (!hash) return '';
return hash.substring(0, 10) + '...' + hash.substring(hash.length - 8);
};
const formatDataValue = (value) => {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value;
};
const viewDetails = (event) => {
selectedEvent.value = event;
showDetailsModal.value = true;
};
const viewOnExplorer = (event) => {
// Здесь будет логика открытия в блокчейн эксплорере
window.open(`https://etherscan.io/tx/${event.transactionHash}`, '_blank');
};
</script>
<style scoped>
.history-container {
padding: 20px;
background-color: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-top: 20px;
margin-bottom: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header-content {
flex-grow: 1;
}
.page-header h1 {
color: var(--color-primary);
font-size: 2.5rem;
margin: 0 0 10px 0;
}
.page-header p {
color: var(--color-grey-dark);
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
flex-shrink: 0;
}
.close-btn:hover {
background: #f0f0f0;
color: #333;
}
/* Секции */
.filters-section,
.stats-section,
.history-section {
margin-bottom: 40px;
}
.filters-section h2,
.stats-section h2,
.history-section h2 {
color: var(--color-primary);
margin-bottom: 20px;
font-size: 1.8rem;
}
/* Фильтры */
.filters-form {
background: #f8f9fa;
padding: 25px;
border-radius: var(--radius-lg);
border: 1px solid #e9ecef;
}
.filters-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-group label {
font-weight: 600;
color: var(--color-grey-dark);
}
.filter-group input,
.filter-group select {
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--radius-sm);
font-size: 1rem;
}
.filters-actions {
display: flex;
gap: 15px;
}
/* Статистика */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: #f8f9fa;
padding: 25px;
border-radius: var(--radius-lg);
border-left: 4px solid var(--color-primary);
text-align: center;
}
.stat-card h3 {
color: var(--color-primary);
margin-bottom: 15px;
font-size: 1rem;
text-transform: uppercase;
font-weight: 600;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
color: var(--color-primary);
}
.stat-value.success {
color: #28a745;
}
.stat-value.error {
color: #dc3545;
}
.stat-value.pending {
color: #ffc107;
}
/* История операций */
.history-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
}
.search-box {
flex-grow: 1;
}
.search-box input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: var(--radius-sm);
font-size: 1rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 10px;
}
.sort-controls select {
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--radius-sm);
font-size: 1rem;
}
.sort-btn {
background: var(--color-secondary);
color: white;
border: none;
padding: 10px 15px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 1rem;
font-weight: 600;
}
/* Список истории */
.history-list {
display: grid;
gap: 20px;
}
.history-item {
display: flex;
gap: 20px;
padding: 25px;
background: white;
border-radius: var(--radius-lg);
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.history-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.history-item.success {
border-left: 4px solid #28a745;
}
.history-item.pending {
border-left: 4px solid #ffc107;
}
.history-item.failed {
border-left: 4px solid #dc3545;
}
.event-icon {
flex-shrink: 0;
}
.event-icon .icon {
font-size: 2rem;
display: block;
}
.event-content {
flex-grow: 1;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.event-header h3 {
margin: 0;
color: var(--color-primary);
font-size: 1.2rem;
}
.event-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.event-status.success {
background: #d4edda;
color: #155724;
}
.event-status.pending {
background: #fff3cd;
color: #856404;
}
.event-status.failed {
background: #f8d7da;
color: #721c24;
}
.event-description {
margin: 0 0 15px 0;
color: var(--color-grey-dark);
line-height: 1.5;
}
.event-meta {
display: flex;
gap: 20px;
margin-bottom: 15px;
font-size: 0.9rem;
color: var(--color-grey-dark);
}
.event-hash,
.event-block {
font-family: monospace;
}
.event-data {
display: grid;
gap: 8px;
}
.data-item {
display: flex;
gap: 10px;
font-size: 0.9rem;
}
.data-label {
font-weight: 600;
color: var(--color-grey-dark);
min-width: 120px;
}
.data-value {
color: var(--color-primary);
}
.event-actions {
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
}
/* Пагинация */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 30px;
}
.page-btn {
background: var(--color-secondary);
color: white;
border: none;
padding: 10px 15px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s ease;
}
.page-btn:hover:not(:disabled) {
background: var(--color-secondary-dark);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 1rem;
color: var(--color-grey-dark);
}
/* Модальное окно */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e9ecef;
}
.modal-header h3 {
margin: 0;
color: var(--color-primary);
}
.modal-body {
padding: 20px;
}
.event-details-full {
display: grid;
gap: 15px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.detail-label {
font-weight: 600;
color: var(--color-grey-dark);
}
.detail-value {
color: var(--color-primary);
font-family: monospace;
word-break: break-all;
}
.detail-section {
margin-top: 20px;
}
.detail-section h4 {
color: var(--color-primary);
margin-bottom: 15px;
}
.data-grid {
display: grid;
gap: 10px;
}
.data-item-full {
display: flex;
gap: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: var(--radius-sm);
}
/* Кнопки */
.btn-primary {
background: var(--color-primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-secondary {
background: var(--color-secondary);
color: white;
border: none;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: var(--color-secondary-dark);
}
/* Состояния */
.empty-state {
text-align: center;
padding: 60px;
color: var(--color-grey-dark);
background: #f8f9fa;
border-radius: var(--radius-lg);
border: 2px dashed #dee2e6;
}
.empty-state p {
margin: 0;
font-size: 1.1rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.filters-row {
grid-template-columns: 1fr;
}
.history-controls {
flex-direction: column;
align-items: stretch;
}
.history-item {
flex-direction: column;
gap: 15px;
}
.event-actions {
flex-direction: row;
justify-content: flex-start;
}
.event-meta {
flex-direction: column;
gap: 5px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.filters-actions {
flex-direction: column;
}
.detail-row {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
}
</style>