feat: новая функция

This commit is contained in:
2025-11-01 19:32:50 +03:00
parent e28848146d
commit 06f31825f3
13 changed files with 784 additions and 139 deletions

View File

@@ -42,6 +42,7 @@
import { ref, watch, onMounted, computed, onUnmounted } from 'vue';
import { RouterView } from 'vue-router';
import { useAuth, provideAuth } from './composables/useAuth';
import { provideFooterDle } from './composables/useFooterDle';
import { useTokenBalancesWebSocket } from './composables/useTokenBalancesWebSocket';
import eventBus from './utils/eventBus';
import wsClient from './utils/websocket';
@@ -64,6 +65,8 @@
// --- Логика загрузки баланса токенов через WebSocket ---
// Предоставляем auth контекст
provideAuth();
// Предоставляем контекст для выбранного DLE в футере
provideFooterDle();
// Инициализируем WebSocket composable
const {

View File

@@ -12,7 +12,13 @@
<template>
<div class="chat-container" :style="{ '--chat-input-height': chatInputHeight + 'px' }">
<div ref="messagesContainer" class="chat-messages" @scroll="handleScroll">
<!-- Блок истории сообщений -->
<div
ref="messagesContainer"
class="chat-messages"
:style="{ width: messagesWidth + '%' }"
@scroll="handleScroll"
>
<div v-for="message in messages" :key="message.id" :class="['message-wrapper', { 'selected-message': selectedMessageIds.includes(message.id) }]">
<template v-if="props.canSelectMessages">
<input type="checkbox" class="admin-select-checkbox" :checked="selectedMessageIds.includes(message.id)" @change="() => toggleSelectMessage(message.id)" />
@@ -26,7 +32,19 @@
</div>
</div>
<div ref="chatInputRef" class="chat-input">
<!-- Разделитель для изменения размера -->
<div
class="resizer"
@mousedown="startResize"
@touchstart="startResize"
></div>
<!-- Блок ввода сообщений -->
<div
ref="chatInputRef"
class="chat-input"
:style="{ width: inputWidth + '%' }"
>
<div class="input-area">
<textarea
ref="messageInputRef"
@@ -34,69 +52,73 @@
@input="handleInput"
placeholder="Введите сообщение..."
:disabled="isLoading || !props.canSend"
rows="1"
autofocus
@keydown.enter.prevent="sendMessage"
@focus="handleFocus"
@blur="handleBlur"
/>
<div class="chat-icons">
<button
class="chat-icon-btn"
title="Удерживайте для записи аудио"
@mousedown="startAudioRecording"
@mouseup="stopAudioRecording"
@mouseleave="stopAudioRecording"
:class="{ 'recording': isAudioRecording }"
:disabled="!props.canSend"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" fill="currentColor"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" fill="currentColor"/>
</svg>
</button>
<button
class="chat-icon-btn"
title="Удерживайте для записи видео"
@mousedown="startVideoRecording"
@mouseup="stopVideoRecording"
@mouseleave="stopVideoRecording"
:class="{ 'recording': isVideoRecording }"
:disabled="!props.canSend"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="currentColor"/>
</svg>
</button>
<button class="chat-icon-btn" title="Прикрепить файл" @click="handleFileUpload" :disabled="!props.canSend">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" fill="currentColor"/>
</svg>
</button>
<button class="chat-icon-btn" title="Очистить поле ввода" @click="clearInput">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill="currentColor"/>
</svg>
</button>
<button
class="chat-icon-btn send-button"
title="Отправить сообщение"
:disabled="isSendDisabled"
@click="sendMessage"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" fill="currentColor"/>
</svg>
</button>
<button v-if="props.canGenerateAI" class="chat-icon-btn ai-reply-btn" title="Сгенерировать ответ ІІ" @click="handleAiReply" :disabled="isAiLoading">
<template v-if="isAiLoading">
<svg class="ai-spinner" width="22" height="22" viewBox="0 0 50 50"><circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle></svg>
</template>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="8" r="4"/><path d="M8 16v2M16 16v2"/></svg>
</template>
</button>
</div>
</div>
<div class="chat-icons">
<button
class="chat-icon-btn"
title="Удерживайте для записи аудио"
@mousedown="startAudioRecording"
@mouseup="stopAudioRecording"
@mouseleave="stopAudioRecording"
:class="{ 'recording': isAudioRecording }"
:disabled="!props.canSend"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" fill="currentColor"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" fill="currentColor"/>
</svg>
</button>
<button
class="chat-icon-btn"
title="Удерживайте для записи видео"
@mousedown="startVideoRecording"
@mouseup="stopVideoRecording"
@mouseleave="stopVideoRecording"
:class="{ 'recording': isVideoRecording }"
:disabled="!props.canSend"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="currentColor"/>
</svg>
</button>
<button class="chat-icon-btn" title="Прикрепить файл" @click="handleFileUpload" :disabled="!props.canSend">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" fill="currentColor"/>
</svg>
</button>
<button class="chat-icon-btn" title="Клавиатура" @click="handleKeyboardToggle">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z" fill="currentColor"/>
</svg>
</button>
<button class="chat-icon-btn" title="Очистить поле ввода" @click="clearInput">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill="currentColor"/>
</svg>
</button>
<button
class="chat-icon-btn send-button"
title="Отправить сообщение"
:disabled="isSendDisabled"
@click="sendMessage"
>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" fill="currentColor"/>
</svg>
</button>
<button v-if="props.canGenerateAI" class="chat-icon-btn ai-reply-btn" title="Сгенерировать ответ ІІ" @click="handleAiReply" :disabled="isAiLoading">
<template v-if="isAiLoading">
<svg class="ai-spinner" width="22" height="22" viewBox="0 0 50 50"><circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle></svg>
</template>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="8" r="4"/><path d="M8 16v2M16 16v2"/></svg>
</template>
</button>
</div>
<div class="attachment-preview" v-if="localAttachments.length > 0">
<div v-for="(file, index) in localAttachments" :key="index" class="preview-item">
@@ -367,6 +389,13 @@ const clearInput = () => {
nextTick(adjustTextareaHeight); // Сбросить высоту textarea
};
const handleKeyboardToggle = () => {
// Показываем виртуальную клавиатуру или переключаем режим
if (messageInputRef.value) {
messageInputRef.value.focus();
}
};
// --- Отправка сообщения ---
const isSendDisabled = computed(() => {
return props.isLoading || !props.canSend || (!props.newMessage.trim() && localAttachments.value.length === 0);
@@ -384,6 +413,53 @@ const sendMessage = () => {
nextTick(adjustTextareaHeight); // Сбросить высоту textarea после отправки
};
// --- Изменение размера блоков ---
const messagesWidth = ref(70); // Начальная ширина блока истории (в процентах)
const inputWidth = ref(30); // Начальная ширина блока ввода (в процентах)
const isResizing = ref(false);
const resizeStartX = ref(0);
const resizeStartWidth = ref(0);
const startResize = (e) => {
isResizing.value = true;
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
document.addEventListener('touchmove', handleResize);
document.addEventListener('touchend', stopResize);
e.preventDefault();
};
const handleResize = (e) => {
if (!isResizing.value) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const chatContainer = document.querySelector('.chat-container');
if (!chatContainer) return;
const containerRect = chatContainer.getBoundingClientRect();
const containerWidth = containerRect.width;
const mouseX = clientX - containerRect.left; // Позиция курсора относительно левого края контейнера
// Вычисляем процент ширины блока истории от позиции курсора
const newMessagesWidth = (mouseX / containerWidth) * 100;
// Ограничиваем минимальную и максимальную ширину (от 20% до 80%)
const clampedWidth = Math.max(20, Math.min(80, newMessagesWidth));
messagesWidth.value = clampedWidth;
inputWidth.value = 100 - clampedWidth;
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.removeEventListener('touchmove', handleResize);
document.removeEventListener('touchend', stopResize);
};
// --- Прокрутка и UI ---
const scrollToBottom = () => {
if (messagesContainer.value) {
@@ -529,33 +605,116 @@ async function handleAiReply() {
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
flex-direction: row;
height: 100%;
max-height: 100%;
min-height: 0;
position: relative;
overflow: hidden;
gap: 0;
}
/* На мобильных устройствах возвращаем вертикальный layout */
@media (max-width: 1024px) {
.chat-container {
flex-direction: column;
}
}
.chat-messages {
flex: 1 1 auto;
flex: 0 0 auto;
overflow-y: auto;
position: relative;
padding-bottom: 8px;
padding: var(--spacing-md) var(--spacing-md);
min-height: 0;
background-color: #ffffff;
border-right: none;
/* Скрываем скроллбар, но сохраняем функциональность скролла */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE и Edge */
transition: width 0.1s ease;
}
.chat-messages::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* На мобильных устройствах история занимает всё пространство */
@media (max-width: 1024px) {
.chat-messages {
flex: 1 1 auto;
width: 100% !important;
border-right: none;
padding: var(--spacing-md) var(--spacing-md) 8px;
/* Скрываем скроллбар и на мобильных */
scrollbar-width: none;
-ms-overflow-style: none;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
.resizer {
display: none;
}
.chat-input {
width: 100% !important;
}
}
.resizer {
width: 4px;
flex-shrink: 0;
background-color: transparent;
cursor: col-resize;
position: relative;
user-select: none;
transition: background-color 0.2s ease;
}
.resizer:hover {
background-color: var(--color-primary, #4CAF50);
}
.resizer::before {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 0;
bottom: 0;
background-color: transparent;
}
.chat-input {
position: relative;
width: 100%;
margin-bottom: 12px;
margin-top: 8px;
flex: 0 0 auto;
margin: 0;
left: 0;
right: 0;
border-radius: 12px 12px 0 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
flex-shrink: 0;
border-radius: 0;
min-height: 80px;
height: 100%;
padding: var(--spacing-md) var(--spacing-md) 0;
box-sizing: border-box;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.1s ease;
}
/* На мобильных устройствах блок ввода занимает всё пространство внизу */
@media (max-width: 1024px) {
.chat-input {
width: 100%;
max-width: 100%;
height: auto;
border-top: none;
border-right: none;
}
}
@@ -563,80 +722,136 @@ async function handleAiReply() {
.chat-input textarea {
width: 100%;
flex: 1 1 auto;
min-height: 0;
border: none;
background: transparent;
background: #f5f5f5;
border-radius: 12px;
resize: none;
outline: none;
font-size: var(--font-size-md);
line-height: 1.5;
padding: var(--spacing-sm);
min-height: var(--chat-input-min-height, 40px);
max-height: var(--chat-input-max-height, 120px);
padding: 16px;
margin: 0;
transition: all var(--transition-fast);
color: var(--color-dark);
overflow-y: hidden;
height: auto;
overflow-y: auto;
box-sizing: border-box;
}
.chat-input textarea:focus {
outline: none;
border: none;
background: #f5f5f5;
}
/* На мобильных устройствах поле ввода меньше */
@media (max-width: 1024px) {
.chat-input textarea {
border-radius: 20px;
padding: 12px 16px;
min-height: var(--chat-input-min-height, 40px);
max-height: var(--chat-input-max-height, 120px);
overflow-y: hidden;
resize: none;
}
}
.input-area {
display: flex;
align-items: flex-end;
gap: var(--spacing-sm);
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
flex-direction: column;
}
.chat-icons {
display: flex;
gap: 6px;
flex-wrap: nowrap;
gap: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
margin: 0;
margin-top: 0;
width: 100%;
padding: var(--spacing-sm) 0;
border-top: none;
}
.chat-icon-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: transparent;
border: none;
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-grey);
padding: 0;
color: var(--color-dark, #333);
transition: all 0.2s ease;
position: relative;
font-weight: 500;
flex-shrink: 0;
}
.chat-icon-btn:hover {
.chat-icon-btn svg {
width: 22px;
height: 22px;
flex-shrink: 0;
}
.chat-icon-btn:hover:not(:disabled) {
color: var(--color-primary);
background-color: rgba(0, 0, 0, 0.05);
background-color: #ffffff;
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chat-icon-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.chat-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f5f5;
border-color: #e0e0e0;
}
.chat-icon-btn.send-button {
background-color: var(--color-primary);
background-color: var(--color-primary, #4CAF50);
color: white;
width: 36px;
height: 36px;
border-color: var(--color-primary, #4CAF50);
font-weight: 600;
width: 40px;
height: 40px;
}
.chat-icon-btn.send-button:hover:not(:disabled) {
background-color: var(--color-primary-dark);
background-color: var(--color-primary-dark, #45a049);
border-color: var(--color-primary-dark, #45a049);
color: white;
transform: scale(1.05);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(76, 175, 80, 0.3);
}
.chat-icon-btn.send-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(76, 175, 80, 0.2);
}
.chat-icon-btn.send-button:disabled {
background-color: #ccc;
border-color: #ccc;
opacity: 0.7;
}
@@ -666,11 +881,12 @@ async function handleAiReply() {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--color-grey-light);
max-height: 100px;
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.preview-item {
@@ -725,11 +941,12 @@ async function handleAiReply() {
}
.chat-messages {
padding: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-md) 8px;
}
.chat-input {
padding: var(--spacing-xs) var(--spacing-sm);
height: auto;
}
.chat-icon-btn {
@@ -753,7 +970,7 @@ async function handleAiReply() {
border-top: 1px solid #eee !important;
}
.chat-messages {
padding: var(--spacing-md) !important;
padding: var(--spacing-md) var(--spacing-md) 8px !important;
overflow-y: auto !important;
}
}
@@ -768,7 +985,7 @@ async function handleAiReply() {
border-top: 1px solid #eee !important;
}
.chat-messages {
padding: var(--spacing-md) !important;
padding: var(--spacing-md) var(--spacing-md) 8px !important;
overflow-y: auto !important;
}
.chat-container {

View File

@@ -14,6 +14,17 @@
<div class="header">
<div class="header-content">
<div class="header-text">
<div v-if="dleDisplayName" class="footer-dle-info">
<img
v-if="footerDle?.logoURI"
:src="footerDle.logoURI"
:alt="dleDisplayName.name"
class="footer-dle-logo"
@error="handleLogoError"
/>
<div v-else class="footer-dle-logo-placeholder">DLE</div>
<span class="dle-name">{{ dleDisplayName.name }} ({{ dleDisplayName.symbol }})</span>
</div>
</div>
<button
class="header-wallet-btn"
@@ -27,8 +38,9 @@
</template>
<script setup>
import { defineProps, defineEmits, onMounted, onBeforeUnmount, watch } from 'vue';
import { defineProps, defineEmits, onMounted, onBeforeUnmount, watch, computed } from 'vue';
import { useAuthContext } from '../composables/useAuth';
import { useFooterDle } from '../composables/useFooterDle';
import eventBus from '../utils/eventBus';
const props = defineProps({
@@ -48,8 +60,44 @@ const toggleSidebar = () => {
const auth = useAuthContext();
const { isAuthenticated } = auth;
// Используем composable для выбранного DLE
const { footerDle } = useFooterDle();
// Вычисляемое свойство для отображения названия
const dleDisplayName = computed(() => {
if (!footerDle.value || !footerDle.value.name || !footerDle.value.symbol) return null;
// Проверяем, что это не fallback данные (не начинается с "DLE " и адресом)
if (footerDle.value.name.startsWith('DLE ') && footerDle.value.name.includes('...')) {
return null; // Не показываем fallback данные
}
return {
name: footerDle.value.name,
symbol: footerDle.value.symbol
};
});
// Обработка ошибки загрузки логотипа
const handleLogoError = (event) => {
console.log('[Header] Ошибка загрузки логотипа:', event.target.src);
event.target.style.display = 'none';
// Показываем placeholder, если его нет
const infoContainer = event.target.closest('.footer-dle-info');
if (infoContainer) {
let placeholder = infoContainer.querySelector('.footer-dle-logo-placeholder');
if (!placeholder) {
placeholder = document.createElement('div');
placeholder.className = 'footer-dle-logo-placeholder';
placeholder.textContent = 'DLE';
infoContainer.insertBefore(placeholder, event.target);
}
placeholder.style.display = 'flex';
}
};
// Мониторинг изменений статуса аутентификации
let unwatch = null;
let refreshInterval = null;
onMounted(() => {
// Следим за изменениями авторизации и сообщаем о них через eventBus
unwatch = watch(isAuthenticated, (newValue, oldValue) => {
@@ -63,17 +111,14 @@ onMounted(() => {
}
});
// Подписываемся на централизованные события очистки и обновления данных
window.addEventListener('clear-application-data', () => {
console.log('[Header] Clearing header data');
// Очищаем данные при выходе из системы
// Header не нуждается в очистке данных
});
// Обновляем данные DLE из блокчейна периодически (каждые 5 минут)
const { refreshFooterDle } = useFooterDle();
refreshInterval = setInterval(() => {
refreshFooterDle();
}, 5 * 60 * 1000); // 5 минут
window.addEventListener('refresh-application-data', () => {
console.log('[Header] Refreshing header data');
// Header не нуждается в обновлении данных
});
// НЕ очищаем footerDle при отключении кошелька, так как это глобальная настройка,
// не связанная с пользовательским кошельком
});
// Очищаем наблюдатель при удалении компонента
@@ -81,6 +126,10 @@ onBeforeUnmount(() => {
if (unwatch) {
unwatch();
}
// Очищаем интервал обновления
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
@@ -102,6 +151,45 @@ onBeforeUnmount(() => {
.header-text {
flex-grow: 1;
display: flex;
align-items: center;
}
.footer-dle-info {
display: flex;
align-items: center;
gap: 10px;
}
.footer-dle-logo {
width: 32px;
height: 32px;
border-radius: 6px;
object-fit: contain;
border: 2px solid #e9ecef;
background: white;
}
.footer-dle-logo-placeholder {
width: 32px;
height: 32px;
border-radius: 6px;
background: linear-gradient(135deg, var(--color-primary), #0056b3);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
border: 2px solid #e9ecef;
flex-shrink: 0;
}
.dle-name {
font-size: 0.9rem;
color: var(--color-primary);
font-weight: 500;
white-space: nowrap;
}
.title {

View File

@@ -60,7 +60,7 @@
<h4 class="consent-document-title">{{ doc.title }}</h4>
<p v-if="doc.summary" class="consent-document-summary">{{ doc.summary }}</p>
<a
:href="`/content/published/${doc.id}`"
:href="`/public/page/${doc.id}`"
target="_blank"
class="consent-document-link"
@click.stop
@@ -371,7 +371,6 @@ function copyEmail(email) {
max-width: 75%;
word-wrap: break-word;
position: relative;
box-shadow: var(--shadow-sm);
}
.user-message {
@@ -603,7 +602,7 @@ function copyEmail(email) {
.consent-document-item:hover {
border-color: var(--color-primary, #007bff);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
background: #f8f9fa;
}
.consent-document-item:last-child {
@@ -718,7 +717,6 @@ function copyEmail(email) {
margin-right: 0;
max-width: 70%;
border-radius: 18px 18px 4px 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.private-other-user {
@@ -728,7 +726,6 @@ function copyEmail(email) {
margin-right: auto;
max-width: 70%;
border-radius: 18px 18px 18px 4px;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
/* Анимация появления сообщений */

View File

@@ -0,0 +1,160 @@
/**
* 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/VC-HB3-Accelerator
*/
import { ref, provide, inject } from 'vue';
import api from '../api/axios';
import { getFromStorage, setToStorage } from '../utils/storage';
// === SINGLETON STATE ===
const footerDle = ref(null);
const isLoading = ref(false);
const selectedDleAddress = ref(null);
// Загрузка адреса выбранного DLE из localStorage при инициализации
function loadSavedDleAddress() {
try {
const savedAddress = getFromStorage('footerDleAddress', null);
if (savedAddress) {
selectedDleAddress.value = savedAddress;
// Загружаем актуальные данные из блокчейна
loadDleFromBlockchain(savedAddress).then((loadedDle) => {
if (loadedDle) {
footerDle.value = loadedDle;
}
});
}
} catch (error) {
console.warn('[useFooterDle] Ошибка при загрузке адреса из localStorage:', error);
}
}
// Инициализация при загрузке модуля
loadSavedDleAddress();
// === API ===
/**
* Загружает данные DLE из блокчейна по адресу
*/
async function loadDleFromBlockchain(dleAddress) {
try {
isLoading.value = true;
const response = await api.post('/blockchain/read-dle-info', {
dleAddress: dleAddress
});
if (response.data.success) {
const blockchainData = response.data.data;
return {
address: dleAddress,
name: blockchainData.name || '',
symbol: blockchainData.symbol || '',
logoURI: blockchainData.logoURI || ''
};
}
return null;
} catch (error) {
console.error('[useFooterDle] Ошибка при загрузке DLE из блокчейна:', error);
return null;
} finally {
isLoading.value = false;
}
}
/**
* Устанавливает выбранный DLE для отображения в футере
* Сохраняет только адрес, данные всегда загружаются из блокчейна
* @param {string} dleAddress - Адрес DLE
*/
async function setFooterDle(dleAddress) {
if (!dleAddress) {
footerDle.value = null;
selectedDleAddress.value = null;
// Удаляем из localStorage
try {
setToStorage('footerDleAddress', null);
} catch (error) {
console.warn('[useFooterDle] Ошибка при удалении из localStorage:', error);
}
return;
}
// Сохраняем только адрес
selectedDleAddress.value = dleAddress;
setToStorage('footerDleAddress', dleAddress);
// Всегда загружаем актуальные данные из блокчейна
const loadedDle = await loadDleFromBlockchain(dleAddress);
if (loadedDle) {
footerDle.value = loadedDle;
} else {
// Если не удалось загрузить, очищаем состояние
footerDle.value = null;
}
}
/**
* Обновляет данные выбранного DLE из блокчейна
* Используется для периодического обновления или при необходимости
*/
async function refreshFooterDle() {
if (!selectedDleAddress.value) {
return;
}
const loadedDle = await loadDleFromBlockchain(selectedDleAddress.value);
if (loadedDle) {
footerDle.value = loadedDle;
}
}
/**
* Очищает выбранный DLE
*/
function clearFooterDle() {
footerDle.value = null;
selectedDleAddress.value = null;
// Удаляем адрес из localStorage
try {
setToStorage('footerDleAddress', null);
} catch (error) {
console.warn('[useFooterDle] Ошибка при очистке localStorage:', error);
}
}
// === SINGLETON API ===
const footerDleApi = {
footerDle,
isLoading,
selectedDleAddress,
setFooterDle,
clearFooterDle,
refreshFooterDle,
loadDleFromBlockchain
};
// === PROVIDE/INJECT HELPERS ===
const FOOTER_DLE_KEY = Symbol('footerDle');
export function provideFooterDle() {
provide(FOOTER_DLE_KEY, footerDleApi);
}
export function useFooterDle() {
const ctx = inject(FOOTER_DLE_KEY);
if (!ctx) {
// Если контекст не предоставлен, возвращаем singleton напрямую
return footerDleApi;
}
return ctx;
}

View File

@@ -54,7 +54,7 @@ export async function connectWithWallet() {
const docsResponse = await axios.get('/consent/documents');
if (docsResponse.data && docsResponse.data.length > 0) {
docsResponse.data.forEach(doc => {
resources.push(`${window.location.origin}/content/published/${doc.id}`);
resources.push(`${window.location.origin}/public/page/${doc.id}`);
});
}
} catch (error) {

View File

@@ -70,7 +70,7 @@ export const connectWallet = async () => {
const docsResponse = await axios.get('/consent/documents');
if (docsResponse.data && docsResponse.data.length > 0) {
docsResponse.data.forEach(doc => {
resources.push(`${window.location.origin}/content/published/${doc.id}`);
resources.push(`${window.location.origin}/public/page/${doc.id}`);
});
}
} catch (error) {

View File

@@ -197,8 +197,6 @@
.chat-wrapper {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
overflow: hidden;
flex: 1;
min-height: 0;

View File

@@ -2970,6 +2970,9 @@ const handleDeploymentCompleted = (result) => {
console.log('🎉 Поэтапный деплой завершен:', result);
showDeploymentWizard.value = false;
// Эмитируем событие о завершении деплоя для обновления Header
eventBus.emit('dle-deployed', result);
// Перенаправляем на главную страницу управления
router.push('/management');
};

View File

@@ -32,6 +32,53 @@
<!-- Основной контент -->
<div v-if="dleInfo" class="main-content">
<!-- Отображение в футере -->
<div v-if="canSetFooterDle" class="footer-card">
<div class="footer-header">
<h3>Отображение в футере</h3>
</div>
<div class="footer-content">
<p>Выберите этот DLE для отображения в футере приложения. Название будет показано в строке с кнопкой бургера.</p>
<div v-if="isSelectedForFooter" class="selected-info">
<i class="fas fa-check-circle"></i>
<span>Этот DLE отображается в футере</span>
</div>
<div v-else-if="hasFooterDle" class="other-selected-info">
<i class="fas fa-info-circle"></i>
<span>В футере отображается другой DLE: {{ footerDle.value?.name }} ({{ footerDle.value?.symbol }})</span>
</div>
<div class="footer-actions">
<button
v-if="!isSelectedForFooter"
@click="setAsFooterDle"
class="btn-primary"
:disabled="isLoading"
>
<i class="fas fa-eye"></i>
Отображать в футере
</button>
<button
v-if="isSelectedForFooter"
@click="removeFromFooter"
class="btn-danger"
:disabled="isLoading"
>
<i class="fas fa-trash"></i>
Удалить из футера
</button>
<button
v-if="hasFooterDle && !isSelectedForFooter"
@click="removeFromFooter"
class="btn-danger btn-sm"
:disabled="isLoading"
>
<i class="fas fa-trash"></i>
Удалить из футера
</button>
</div>
</div>
</div>
<!-- Удаление DLE -->
<div class="danger-card">
<div class="danger-header">
@@ -68,9 +115,12 @@
</template>
<script setup>
import { ref, defineProps, defineEmits, onMounted } from 'vue';
import { ref, defineProps, defineEmits, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthContext } from '../../composables/useAuth';
import { useFooterDle } from '../../composables/useFooterDle';
import { usePermissions } from '../../composables/usePermissions';
import { ROLES } from '../../composables/permissions';
import BaseLayout from '../../components/BaseLayout.vue';
import { deactivateDLE } from '../../utils/dle-contract.js';
import api from '../../api/axios';
@@ -110,6 +160,74 @@ const goBackToBlocks = () => {
// Получаем адрес пользователя из контекста аутентификации
const { address: userAddress } = useAuthContext();
// Используем composable для проверки прав доступа
const { currentRole } = usePermissions();
// Используем composable для выбранного DLE
const { footerDle, setFooterDle, clearFooterDle } = useFooterDle();
// Проверяем, может ли пользователь устанавливать DLE для футера (только редактор)
const canSetFooterDle = computed(() => {
return currentRole.value === ROLES.EDITOR;
});
// Проверяем, выбран ли этот DLE для отображения в футере
const isSelectedForFooter = computed(() => {
if (!address || !footerDle.value) return false;
// Сравниваем адреса в нижнем регистре для надежности
return footerDle.value.address && footerDle.value.address.toLowerCase() === address.toLowerCase();
});
// Проверяем, есть ли какой-либо DLE в футере
const hasFooterDle = computed(() => {
return footerDle.value !== null && footerDle.value.address !== null;
});
// Устанавливает выбранный DLE для отображения в футере
const setAsFooterDle = async () => {
// Проверяем права доступа (только редактор может устанавливать DLE для футера)
if (!canSetFooterDle.value) {
alert('❌ Только пользователи с ролью редактор могут устанавливать DLE для отображения в футере');
return;
}
if (!dleInfo.value || !address) {
alert('Информация о DLE не загружена');
return;
}
try {
// Устанавливаем адрес, данные будут загружены из блокчейна
await setFooterDle(address);
alert(`✅ DLE "${dleInfo.value.name} (${dleInfo.value.symbol})" теперь отображается в футере приложения`);
} catch (error) {
console.error('Ошибка при установке выбранного DLE:', error);
alert('❌ Не удалось установить выбранный DLE');
}
};
// Удаляет DLE из футера
const removeFromFooter = async () => {
// Проверяем права доступа (только редактор может удалять DLE из футера)
if (!canSetFooterDle.value) {
alert('❌ Только пользователи с ролью редактор могут удалять DLE из футера');
return;
}
if (!confirm('Вы уверены, что хотите удалить этот DLE из футера?')) {
return;
}
try {
clearFooterDle();
alert('✅ DLE удален из футера приложения');
} catch (error) {
console.error('Ошибка при удалении DLE из футера:', error);
alert('❌ Не удалось удалить DLE из футера');
}
};
// Подписываемся на централизованные события очистки и обновления данных
onMounted(() => {
window.addEventListener('clear-application-data', () => {
@@ -136,7 +254,7 @@ const loadDLEInfo = async () => {
console.log('Загружаем информацию о DLE:', address);
// Загружаем данные DLE из блокчейна через API
const response = await api.post('/dle-core/read-dle-info', {
const response = await api.post('/blockchain/read-dle-info', {
dleAddress: address
});
@@ -147,7 +265,8 @@ const loadDLEInfo = async () => {
dleInfo.value = {
name: dleData.name, // Название DLE из блокчейна
symbol: dleData.symbol, // Символ DLE из блокчейна
address: dleData.dleAddress || address // Адрес из API или из URL
address: dleData.dleAddress || address, // Адрес из API или из URL
logoURI: dleData.logoURI || '' // URL логотипа
};
} else {
console.error('Ошибка загрузки DLE:', response.data.error);
@@ -156,12 +275,9 @@ const loadDLEInfo = async () => {
} catch (error) {
console.error('Ошибка при загрузке информации о DLE:', error);
// В случае ошибки показываем базовую информацию
dleInfo.value = {
name: 'DLE ' + address.slice(0, 8) + '...',
symbol: 'DLE',
address: address
};
// В случае ошибки НЕ устанавливаем fallback данные, оставляем null
// чтобы не показывать некорректную информацию
dleInfo.value = null;
} finally {
isLoading.value = false;
}
@@ -302,6 +418,7 @@ onMounted(() => {
}
/* Карточки */
.footer-card,
.danger-card {
background: white;
border: 1px solid #e9ecef;
@@ -309,6 +426,75 @@ onMounted(() => {
overflow: hidden;
}
.footer-header {
background: #f0f7ff;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
}
.footer-header h3 {
color: var(--color-primary);
margin: 0;
font-size: 1.2rem;
}
.footer-content {
padding: 20px;
}
.footer-content p {
color: var(--color-grey-dark);
margin-bottom: 15px;
line-height: 1.5;
}
.footer-actions {
display: flex;
gap: 10px;
align-items: center;
}
.selected-info {
display: flex;
align-items: center;
gap: 8px;
background: #e6f7e6;
border: 1px solid #b3e5b3;
border-radius: 6px;
padding: 10px 15px;
margin-bottom: 15px;
color: #2d5a2d;
font-weight: 500;
}
.selected-info i {
color: #28a745;
font-size: 1.1rem;
}
.other-selected-info {
display: flex;
align-items: center;
gap: 8px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 10px 15px;
margin-bottom: 15px;
color: #856404;
font-weight: 500;
}
.other-selected-info i {
color: #ffc107;
font-size: 1.1rem;
}
.btn-sm {
padding: 8px 16px;
font-size: 0.875rem;
}
.danger-header {
background: #f8f9fa;
padding: 15px 20px;