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

This commit is contained in:
2025-11-01 17:25:49 +03:00
parent 772d4cff54
commit e28848146d
19 changed files with 1680 additions and 67 deletions

View File

@@ -21,6 +21,7 @@
:message="message"
:isPrivateChat="isPrivateChat"
:currentUserId="currentUserId"
@consent-granted="handleConsentGranted"
/>
</div>
</div>
@@ -113,6 +114,7 @@
</div>
</div>
</div>
</div>
</template>
@@ -148,6 +150,7 @@ const emit = defineEmits([
'send-message',
'load-more', // Событие для загрузки старых сообщений
'ai-reply',
'remove-consent-messages', // Событие для удаления системных сообщений о согласиях
]);
const messagesContainer = ref(null);
@@ -155,6 +158,11 @@ const messageInputRef = ref(null);
const chatInputRef = ref(null); // Ref для chat-input
const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее)
function handleConsentGranted(messageId) {
// После подписания удаляем системное сообщение о необходимости согласия
emit('remove-consent-messages', [messageId]);
}
// Локальное состояние для предпросмотра, синхронизированное с props.attachments
const localAttachments = ref([...props.attachments]);
watch(() => props.attachments, (newVal) => {

View File

@@ -0,0 +1,341 @@
<!--
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
-->
<template>
<div v-if="isOpen" class="consent-modal-overlay" @click.self="close">
<div class="consent-modal">
<div class="consent-modal-header">
<h2>Подписание документов</h2>
<button class="close-btn" @click="close">×</button>
</div>
<div class="consent-modal-content">
<p class="consent-description">
Для полноценного использования сервиса необходимо ознакомиться и подписать следующие документы:
</p>
<div v-if="loading" class="loading-state">
<p>Загрузка документов...</p>
</div>
<div v-else-if="documents.length === 0" class="empty-state">
<p>Документы не найдены</p>
</div>
<div v-else class="documents-list">
<div v-for="doc in documents" :key="doc.id" class="document-item">
<label class="document-checkbox">
<input
type="checkbox"
:value="doc.id"
v-model="selectedDocuments"
class="checkbox-input"
/>
<div class="document-info">
<h3 class="document-title">{{ doc.title }}</h3>
<p v-if="doc.summary" class="document-summary">{{ doc.summary }}</p>
<a
:href="`/content/published/${doc.id}`"
target="_blank"
class="document-link"
@click.stop
>
Открыть документ
</a>
</div>
</label>
</div>
</div>
</div>
<div class="consent-modal-footer">
<button class="btn-secondary" @click="close">Отмена</button>
<button
class="btn-primary"
@click="submitConsent"
:disabled="selectedDocuments.length === 0 || submitting"
>
{{ submitting ? 'Подписание...' : 'Подписать' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import api from '../api/axios';
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
missingConsents: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'consent-granted']);
const documents = ref([]);
const selectedDocuments = ref([]);
const loading = ref(false);
const submitting = ref(false);
// Загружаем документы для подписания
async function loadDocuments() {
loading.value = true;
try {
const response = await api.get('/consent/documents');
documents.value = response.data || [];
// Автоматически выбираем все документы
selectedDocuments.value = documents.value.map(doc => doc.id);
} catch (error) {
console.error('Ошибка загрузки документов:', error);
documents.value = [];
} finally {
loading.value = false;
}
}
// Отправляем согласие
async function submitConsent() {
if (selectedDocuments.value.length === 0) return;
submitting.value = true;
try {
// Получаем типы согласий для выбранных документов
const consentTypes = documents.value
.filter(doc => selectedDocuments.value.includes(doc.id))
.map(doc => doc.consentType)
.filter(type => type);
await api.post('/consent/grant', {
documentIds: selectedDocuments.value,
consentTypes: consentTypes,
});
emit('consent-granted');
close();
} catch (error) {
console.error('Ошибка подписания документов:', error);
alert('Ошибка при подписании документов. Попробуйте еще раз.');
} finally {
submitting.value = false;
}
}
function close() {
emit('close');
selectedDocuments.value = [];
}
// Загружаем документы при открытии модалки
watch(() => props.isOpen, (newValue) => {
if (newValue) {
loadDocuments();
}
});
onMounted(() => {
if (props.isOpen) {
loadDocuments();
}
});
</script>
<style scoped>
.consent-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;
}
.consent-modal {
background: white;
border-radius: 12px;
max-width: 600px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.consent-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e9ecef;
}
.consent-modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--color-primary, #333);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: #f0f0f0;
color: #333;
}
.consent-modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.consent-description {
margin: 0 0 20px 0;
color: #666;
line-height: 1.6;
}
.loading-state,
.empty-state {
text-align: center;
padding: 40px 20px;
color: #888;
}
.documents-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.document-item {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.document-item:hover {
border-color: var(--color-primary, #007bff);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.document-checkbox {
display: flex;
gap: 12px;
cursor: pointer;
align-items: flex-start;
}
.checkbox-input {
margin-top: 4px;
width: 18px;
height: 18px;
cursor: pointer;
}
.document-info {
flex: 1;
}
.document-title {
margin: 0 0 8px 0;
font-size: 1.1rem;
color: var(--color-primary, #333);
font-weight: 600;
}
.document-summary {
margin: 0 0 8px 0;
color: #666;
font-size: 0.9rem;
line-height: 1.5;
}
.document-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-top: 8px;
}
.document-link:hover {
text-decoration: underline;
}
.consent-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e9ecef;
}
.btn-secondary,
.btn-primary {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-primary {
background: var(--color-primary, #007bff);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark, #0056b3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -46,8 +46,43 @@
<a :href="replyLink" class="reply-link">Ответить</a>
</div>
<!-- Блок с документами для подписания -->
<div v-if="message.consentRequired && message.consentDocuments" class="consent-documents-block">
<div v-for="doc in message.consentDocuments" :key="doc.id" class="consent-document-item">
<label class="consent-document-label">
<input
type="checkbox"
:value="doc.id"
v-model="selectedConsentDocuments"
class="consent-checkbox"
/>
<div class="consent-document-info">
<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}`"
target="_blank"
class="consent-document-link"
@click.stop
>
Открыть документ
</a>
</div>
</label>
</div>
<div class="consent-actions">
<button
@click="submitConsent"
class="system-btn primary"
:disabled="selectedConsentDocuments.length === 0 || isSubmittingConsent"
>
{{ isSubmittingConsent ? 'Подписание...' : 'Подписать' }}
</button>
</div>
</div>
<!-- Кнопки для системного сообщения -->
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions">
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail) && !message.consentRequired" class="system-actions">
<button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button>
<button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button>
</div>
@@ -117,6 +152,48 @@ const props = defineProps({
},
});
const emit = defineEmits(['consent-granted']);
// Состояние для выбранных документов и отправки согласия
const selectedConsentDocuments = ref([]);
const isSubmittingConsent = ref(false);
// Инициализируем выбранные документы при монтировании, если есть документы
watch(() => props.message.consentDocuments, (docs) => {
if (docs && Array.isArray(docs) && docs.length > 0) {
// Автоматически выбираем все документы
selectedConsentDocuments.value = docs.map(doc => doc.id);
}
}, { immediate: true });
// Функция подписания документов
async function submitConsent() {
if (selectedConsentDocuments.value.length === 0 || isSubmittingConsent.value) return;
isSubmittingConsent.value = true;
try {
const api = (await import('../api/axios')).default;
const documents = props.message.consentDocuments || [];
const consentTypes = documents
.filter(doc => selectedConsentDocuments.value.includes(doc.id))
.map(doc => doc.consentType)
.filter(type => type);
await api.post('/consent/grant', {
documentIds: selectedConsentDocuments.value,
consentTypes: consentTypes,
});
// Уведомляем родительский компонент об успешном подписании
emit('consent-granted', props.message.id);
} catch (error) {
console.error('Ошибка подписания документов:', error);
alert('Ошибка при подписании документов. Попробуйте еще раз.');
} finally {
isSubmittingConsent.value = false;
}
}
// Простая функция для определения, является ли сообщение отправленным текущим пользователем
// Используем данные из самого сообщения для определения направления
const isCurrentUserMessage = computed(() => {
@@ -494,6 +571,97 @@ function copyEmail(email) {
.system-btn:hover {
background: var(--color-primary-dark, #2563eb);
}
.system-btn.primary {
background: var(--color-primary, #007bff);
font-weight: 600;
}
.system-btn.primary:hover {
background: var(--color-primary-dark, #0056b3);
}
.system-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Стили для блока с документами для подписания */
.consent-documents-block {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.consent-document-item {
margin-bottom: 12px;
padding: 12px;
background: white;
border-radius: 6px;
border: 1px solid #e9ecef;
transition: all 0.2s;
}
.consent-document-item:hover {
border-color: var(--color-primary, #007bff);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.consent-document-item:last-child {
margin-bottom: 0;
}
.consent-document-label {
display: flex;
gap: 12px;
cursor: pointer;
align-items: flex-start;
}
.consent-checkbox {
margin-top: 4px;
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.consent-document-info {
flex: 1;
}
.consent-document-title {
margin: 0 0 6px 0;
font-size: 1rem;
color: var(--color-primary, #333);
font-weight: 600;
}
.consent-document-summary {
margin: 0 0 8px 0;
color: #666;
font-size: 0.9rem;
line-height: 1.4;
}
.consent-document-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-top: 4px;
}
.consent-document-link:hover {
text-decoration: underline;
}
.consent-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #e9ecef;
}
/* Стили для информации об отправителе в приватном чате */
.message-sender-info {

View File

@@ -316,6 +316,7 @@ export function useChat(auth) {
}
// Добавляем ответ ИИ, если есть
// Системное сообщение о согласиях уже включено в ответ ИИ
if (response.data.aiResponse) {
messages.value.push({
id: `ai_${Date.now()}`,
@@ -323,12 +324,17 @@ export function useChat(auth) {
sender_type: 'assistant',
role: 'assistant',
timestamp: new Date().toISOString(),
isLocal: false
isLocal: false,
// Добавляем информацию о согласиях, если есть
consentRequired: response.data.consentRequired || false,
missingConsents: response.data.missingConsents || [],
consentDocuments: response.data.consentDocuments || [],
autoConsentOnReply: response.data.autoConsentOnReply || false
});
}
// Добавляем системное сообщение для гостя (только на клиенте, не сохраняется в истории)
if (isGuestMessage && response.data.systemMessage) {
// Добавляем системное сообщение для гостя (только если нет согласия, чтобы не дублировать)
if (isGuestMessage && response.data.systemMessage && !response.data.consentRequired) {
messages.value.push({
id: `system-${Date.now()}`,
content: response.data.systemMessage,

View File

@@ -48,12 +48,30 @@ export async function connectWithWallet() {
throw new Error('Не удалось получить nonce с сервера');
}
// Получаем список документов для подписания
let resources = [`${window.location.origin}/api/auth/verify`];
try {
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}`);
});
}
} catch (error) {
// Если не удалось получить документы, продолжаем без них
console.warn('Не удалось получить список документов для подписания:', error);
}
// Создаем сообщение для подписи
const domain = window.location.host;
const origin = window.location.origin;
const statement = 'Sign in with Ethereum to the app.';
const statement = 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.';
const issuedAt = new Date().toISOString();
// Создаем копию resources и сортируем (не мутируем исходный массив)
const sortedResources = [...resources].sort();
const siweMessage = new SiweMessage({
domain,
address,
@@ -63,7 +81,7 @@ export async function connectWithWallet() {
chainId: 1,
nonce,
issuedAt,
resources: [`${origin}/api/auth/verify`],
resources: sortedResources,
});
const message = siweMessage.prepareMessage();

View File

@@ -43,8 +43,9 @@ export const connectWallet = async () => {
// Берем первый аккаунт в списке
const address = accounts[0];
// Нормализуем адрес (приводим к нижнему регистру для последующих сравнений)
const normalizedAddress = ethers.utils.getAddress(address);
// Нормализуем адрес (используем getAddress для совместимости)
// Проверяем версию ethers - если v6, используем ethers.getAddress, иначе ethers.utils.getAddress
const normalizedAddress = ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address);
// console.log('Normalized address:', normalizedAddress);
// Запрашиваем nonce с сервера
@@ -60,34 +61,70 @@ export const connectWallet = async () => {
};
}
// Создаем провайдер Ethers
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
// Для SIWE используем personal_sign напрямую через window.ethereum
// Не используем ethers signer, так как он добавляет префикс, который нарушает SIWE формат
// Получаем список документов для подписания
let resources = [`${window.location.origin}/api/auth/verify`];
try {
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}`);
});
}
} catch (error) {
// Если не удалось получить документы, продолжаем без них
console.warn('Не удалось получить список документов для подписания:', error);
}
// Создаем сообщение для подписи
const domain = window.location.host;
// Важно: domain должен быть hostname без протокола и порта (если порт стандартный)
const domain = window.location.hostname === 'localhost' ?
`localhost:${window.location.port}` :
window.location.hostname;
const origin = window.location.origin;
// Создаем issuedAt один раз, чтобы использовать одинаковый в сообщении и запросе
const issuedAt = new Date().toISOString();
// Создаем SIWE сообщение
// Создаем копию resources и сортируем (не мутируем исходный массив)
const sortedResources = [...resources].sort();
// Создаем SIWE сообщение с документами в resources
const message = new SiweMessage({
domain,
address: normalizedAddress,
statement: 'Sign in with Ethereum to the app.',
statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.',
uri: origin,
version: '1',
chainId: 1, // Ethereum mainnet
nonce: nonce,
issuedAt: new Date().toISOString(),
resources: [`${origin}/api/auth/verify`],
issuedAt: issuedAt,
resources: sortedResources,
});
// Получаем строку сообщения для подписи
const messageToSign = message.prepareMessage();
// console.log('SIWE message:', messageToSign);
// Логируем для отладки
console.log('🔐 [Frontend] Domain:', domain);
console.log('🔐 [Frontend] Origin:', origin);
console.log('🔐 [Frontend] Address:', normalizedAddress);
console.log('🔐 [Frontend] Nonce:', nonce);
console.log('🔐 [Frontend] IssuedAt:', issuedAt);
console.log('🔐 [Frontend] Resources:', JSON.stringify(sortedResources));
console.log('🔐 [Frontend] SIWE message to sign:', messageToSign);
console.log('🔐 [Frontend] Message length:', messageToSign.length);
// Запрашиваем подпись
// console.log('Requesting signature...');
const signature = await signer.signMessage(messageToSign);
// Запрашиваем подпись через personal_sign (правильный способ для SIWE)
// personal_sign подписывает сообщение С префиксом "\x19Ethereum Signed Message:\n"
// ethers.verifyMessage() также добавляет этот префикс, поэтому они совместимы
// Параметры: [message, address] - MetaMask принимает строку напрямую
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [messageToSign, normalizedAddress.toLowerCase()],
});
if (!signature) {
return {
@@ -104,7 +141,7 @@ export const connectWallet = async () => {
address: normalizedAddress,
signature,
nonce,
issuedAt: new Date().toISOString(),
issuedAt: issuedAt, // Используем тот же issuedAt, что и в сообщении
};
// console.log('Request data:', requestData);

View File

@@ -38,6 +38,7 @@
v-model:attachments="attachments"
@send-message="handleSendMessage"
@load-more="loadMessages"
@remove-consent-messages="handleRemoveConsentMessages"
/>
</template>
<template v-else>
@@ -50,6 +51,7 @@
v-model:attachments="attachments"
@send-message="handleSendMessage"
@load-more="loadMessages"
@remove-consent-messages="handleRemoveConsentMessages"
/>
</template>
</div>
@@ -155,6 +157,13 @@
loadMessages({ initial: true });
}
};
// Функция удаления системных сообщений о согласиях после подписания
const handleRemoveConsentMessages = (messageIds) => {
if (messageIds && Array.isArray(messageIds)) {
messages.value = messages.value.filter(msg => !messageIds.includes(msg.id));
}
};
</script>
<style scoped>