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

This commit is contained in:
2025-10-23 21:44:14 +03:00
parent 918da882d2
commit 6e21887c3b
17 changed files with 959 additions and 462 deletions

View File

@@ -522,10 +522,11 @@ async function handleAiReply() {
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
max-height: 100vh;
height: 100%;
max-height: 100%;
min-height: 0;
position: relative;
overflow: hidden;
}
.chat-messages {
@@ -533,6 +534,7 @@ async function handleAiReply() {
overflow-y: auto;
position: relative;
padding-bottom: 8px;
min-height: 0;
}
.chat-input {
@@ -544,49 +546,13 @@ async function handleAiReply() {
right: 0;
border-radius: 12px 12px 0 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
min-height: 500px;
width: 100%;
position: relative;
background: transparent;
height: 100%;
}
.chat-messages {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: var(--spacing-lg);
background: transparent;
border-radius: 0;
border: none;
flex: 1;
min-height: 0;
}
.chat-input {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-white);
border-radius: 0;
border: none;
border-top: 1px solid #e9ecef;
flex-shrink: 0;
transition: all var(--transition-normal);
z-index: 10;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
position: sticky;
bottom: 0;
min-height: 80px;
}
.chat-input textarea {
width: 100%;
border: none;

View File

@@ -82,18 +82,19 @@
<thead>
<tr>
<th v-if="canViewContacts"><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
<th>ID</th>
<th>Тип</th>
<th>Имя</th>
<th>Email</th>
<th>Telegram</th>
<th>Кошелек</th>
<th>Дата создания</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="contact in filteredContacts" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<td v-if="canViewContacts"><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
<tr v-for="contact in filteredContacts" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }" @click="goToContactDetails(contact.id)" style="cursor: pointer;">
<td v-if="canViewContacts" @click.stop><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
<td>{{ contact.id }}</td>
<td>
<span
v-if="getRoleDisplayName(contact.role)"
@@ -104,14 +105,10 @@
<span v-else class="user-badge">Неизвестно</span>
</td>
<td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td>
<td>{{ contact.telegram || '-' }}</td>
<td>{{ contact.wallet || '-' }}</td>
<td>{{ maskPersonalData(contact.email) }}</td>
<td>{{ maskPersonalData(contact.telegram) }}</td>
<td>{{ maskPersonalData(contact.wallet) }}</td>
<td>{{ contact.created_at ? new Date(contact.created_at).toLocaleString() : '-' }}</td>
<td>
<span v-if="newMsgUserIds.includes(String(contact.id))" class="new-msg-icon" title="Новое сообщение"></span>
<button class="details-btn" @click="showDetails(contact)">Подробнее</button>
</td>
</tr>
</tbody>
</table>
@@ -132,6 +129,7 @@ import { useTagsWebSocket } from '../composables/useTagsWebSocket';
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
import { usePermissions } from '@/composables/usePermissions';
import { useAuthContext } from '@/composables/useAuth';
import { PERMISSIONS } from '/app/shared/permissions.js';
import api from '../api/axios';
import { sendMessage, getPrivateUnreadCount } from '../services/messagesService';
import { useRoles } from '@/composables/useRoles';
@@ -147,7 +145,7 @@ const contactsArray = computed(() => props.contacts || []);
const newIds = computed(() => props.newContacts.map(c => c.id));
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
const router = useRouter();
const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData } = usePermissions();
const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData, hasPermission } = usePermissions();
const { userAccessLevel, userId, isAuthenticated } = useAuthContext();
const { roles, getRoleDisplayName, getRoleClass, fetchRoles, clearRoles } = useRoles();
@@ -175,6 +173,19 @@ async function loadPrivateUnreadCount() {
}
}
// Функция маскировки персональных данных для читателей
function maskPersonalData(data) {
if (!data || data === '-') return '-';
// Если пользователь имеет права редактора, показываем полные данные
if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) {
return data;
}
// Для читателей маскируем данные полностью звездочками
return '***';
}
// Новый фильтр тегов через мультисвязи
const availableTags = ref([]);
const selectedTagIds = ref([]);
@@ -404,14 +415,14 @@ function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
async function showDetails(contact) {
async function goToContactDetails(contactId) {
if (props.markContactAsRead) {
await props.markContactAsRead(contact.id);
await props.markContactAsRead(contactId);
}
if (props.markMessagesAsReadForUser) {
props.markMessagesAsReadForUser(contact.id);
props.markMessagesAsReadForUser(contactId);
}
router.push({ name: 'contact-details', params: { id: contact.id } });
router.push({ name: 'contact-details', params: { id: contactId } });
}
function onImported() {
@@ -430,7 +441,7 @@ async function openChatForSelected() {
if (!contact) return;
// Открываем чат с этим контактом (user_chat)
await showDetails(contact);
await goToContactDetails(contact.id);
}
// Новая функция для отправки публичного сообщения
@@ -448,7 +459,7 @@ function sendPublicMessage() {
}
// Открываем страницу детали контакта с чатом для публичных сообщений
showDetails(contact);
goToContactDetails(contactId);
}
// Функция для открытия приватного чата

View File

@@ -40,6 +40,11 @@
<!-- Текстовый контент, если есть -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="message.content" class="message-content" v-html="formattedContent" />
<!-- Ссылка "Ответить" для публичных сообщений от других пользователей -->
<div v-if="shouldShowReplyLink" class="message-reply-link">
<a :href="replyLink" class="reply-link">Ответить</a>
</div>
<!-- Кнопки для системного сообщения -->
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions">
@@ -127,6 +132,11 @@ const isCurrentUserMessage = computed(() => {
return props.message.sender_id === props.message.user_id;
}
// Для публичных сообщений сравниваем sender_id с currentUserId
if (props.message.message_type === 'public' && props.currentUserId) {
return props.message.sender_id == props.currentUserId;
}
// Для обычных сообщений используем стандартную логику
return props.message.sender_type === 'user' || props.message.role === 'user';
});
@@ -145,6 +155,22 @@ const formatWalletAddress = (address) => {
return address;
};
// --- Логика ссылки "Ответить" для публичных сообщений ---
const shouldShowReplyLink = computed(() => {
// Показываем ссылку только для публичных сообщений от других пользователей
return props.message.message_type === 'public' &&
!isCurrentUserMessage.value &&
props.message.sender_id &&
props.currentUserId &&
props.message.sender_id !== props.currentUserId;
});
const replyLink = computed(() => {
if (!shouldShowReplyLink.value) return '';
// Ссылка ведет на страницу контакта отправителя
return `/contacts/${props.message.sender_id}`;
});
// --- Работа с вложениями ---
const attachment = computed(() => {
// Ожидаем массив attachments, даже если там только один элемент
@@ -554,6 +580,30 @@ function copyEmail(email) {
}
}
/* Стили для ссылки "Ответить" */
.message-reply-link {
margin-top: var(--spacing-xs);
text-align: right;
}
.reply-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
background-color: rgba(0, 123, 255, 0.1);
transition: all 0.2s ease;
display: inline-block;
}
.reply-link:hover {
background-color: rgba(0, 123, 255, 0.2);
color: var(--color-primary-dark, #0056b3);
text-decoration: none;
}
/* Адаптивность для мобильных устройств */
@media (max-width: 768px) {
.private-current-user,

View File

@@ -105,10 +105,16 @@ export function useChat(auth) {
let totalMessages = -1;
if (initial || messageLoading.value.offset === 0) {
try {
const countResponse = await api.get('/messages/public', { params: { count_only: true } });
if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений');
totalMessages = countResponse.data.total || countResponse.data.count || 0;
// console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`);
// Получаем количество личных сообщений с ИИ
const personalCountResponse = await api.get('/chat/history', { params: { count_only: true } });
const personalCount = personalCountResponse.data.success ? (personalCountResponse.data.total || 0) : 0;
// Получаем количество публичных сообщений
const publicCountResponse = await api.get('/messages/public', { params: { count_only: true } });
const publicCount = publicCountResponse.data.success ? (publicCountResponse.data.total || 0) : 0;
totalMessages = personalCount + publicCount;
// console.log(`[useChat] Всего сообщений в истории: ${totalMessages} (личные: ${personalCount}, публичные: ${publicCount})`);
} catch(countError) {
// console.error('[useChat] Ошибка получения количества сообщений:', countError);
// Не прерываем выполнение, попробуем загрузить без total
@@ -122,13 +128,41 @@ export function useChat(auth) {
// console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`);
}
// Используем новый API для публичных сообщений с пагинацией
const response = await api.get('/messages/public', {
// Загружаем личные сообщения с ИИ
const personalResponse = await api.get('/chat/history', {
params: {
offset: effectiveOffset,
limit: messageLoading.value.limit
}
});
// Загружаем публичные сообщения от других пользователей
const publicResponse = await api.get('/messages/public', {
params: {
offset: 0,
limit: 50
}
});
// Объединяем сообщения
let allMessages = [];
if (personalResponse.data.success && personalResponse.data.messages) {
allMessages = [...allMessages, ...personalResponse.data.messages];
}
if (publicResponse.data.success && publicResponse.data.messages) {
allMessages = [...allMessages, ...publicResponse.data.messages];
}
// Сортируем по времени создания
allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const response = {
data: {
success: true,
messages: allMessages,
total: allMessages.length
}
};
if (response.data.success && response.data.messages) {
const loadedMessages = response.data.messages;

View File

@@ -166,4 +166,13 @@ export async function markPrivateMessagesAsRead(conversationId) {
conversationId
});
return data;
}
// Функция для загрузки личных сообщений с ИИ
export async function getPersonalChatHistory(options = {}) {
const { limit = 50, offset = 0 } = options;
const { data } = await api.get('/chat/history', {
params: { limit, offset }
});
return data;
}

View File

@@ -33,6 +33,7 @@
:messages="messages"
:is-loading="isLoading || isConnectingWallet"
:has-more-messages="messageLoading.hasMoreMessages"
:currentUserId="auth.userId"
v-model:newMessage="newMessage"
v-model:attachments="attachments"
@send-message="handleSendMessage"
@@ -44,6 +45,7 @@
:messages="messages"
:is-loading="isLoading || isConnectingWallet"
:has-more-messages="messageLoading.hasMoreMessages"
:currentUserId="auth.userId"
v-model:newMessage="newMessage"
v-model:attachments="attachments"
@send-message="handleSendMessage"
@@ -161,6 +163,8 @@
background-color: var(--color-white);
border-radius: var(--radius-lg);
height: calc(100vh - 40px);
display: flex;
flex-direction: column;
overflow: hidden;
}
@@ -171,6 +175,7 @@
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
flex-shrink: 0;
}
.header-content h1 {
@@ -190,6 +195,7 @@
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
/* Адаптивность */

View File

@@ -21,7 +21,9 @@
<h2>Детали контакта</h2>
<button class="close-btn" @click="goBack">×</button>
</div>
<div class="contact-info-block">
<div class="contact-info-section">
<div class="contact-info-block">
<div><strong>ID пользователя:</strong> {{ contact.id }}</div>
<div>
<strong>Имя:</strong>
<template v-if="canEditContacts">
@@ -32,9 +34,9 @@
{{ contact.name }}
</template>
</div>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
<div><strong>Email:</strong> {{ maskPersonalData(contact.email) }}</div>
<div><strong>Telegram:</strong> {{ maskPersonalData(contact.telegram) }}</div>
<div><strong>Кошелек:</strong> {{ maskPersonalData(contact.wallet) }}</div>
<div>
<strong>Язык:</strong>
<div class="multi-select">
@@ -101,6 +103,7 @@
<button class="delete-history-btn" @click="deleteMessagesHistory">Удалить историю сообщений</button>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
</div>
</div>
<div class="messages-block">
<h3>Чат с пользователем</h3>
@@ -109,9 +112,10 @@
:isLoading="isLoadingMessages"
:attachments="chatAttachments"
:newMessage="chatNewMessage"
:canSend="canSendToUsers"
:canSend="canSendToUsers && !!address"
:canGenerateAI="canGenerateAI"
:canSelectMessages="canGenerateAI"
:currentUserId="currentUserId"
@send-message="handleSendMessage"
@update:newMessage="val => chatNewMessage = val"
@update:attachments="val => chatAttachments = val"
@@ -160,11 +164,13 @@ import Message from '../../components/Message.vue';
import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js';
import { getPublicMessages, getConversationByUserId } from '../../services/messagesService.js';
import { getPublicMessages, getConversationByUserId, sendMessage, getPersonalChatHistory } from '../../services/messagesService.js';
import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions';
import { PERMISSIONS } from '/app/shared/permissions.js';
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts } = usePermissions();
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
const { address, userId: currentUserId } = useAuthContext();
const { markContactAsRead } = useContactsAndMessagesWebSocket();
// Подписываемся на централизованные события очистки и обновления данных
@@ -214,6 +220,19 @@ const tagsTableId = ref(null);
const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null;
// Функция маскировки персональных данных для читателей
function maskPersonalData(data) {
if (!data || data === '-') return '-';
// Если пользователь имеет права редактора, показываем полные данные
if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) {
return data;
}
// Для читателей маскируем данные полностью звездочками
return '***';
}
async function ensureTagsTable() {
// Получаем все пользовательские таблицы
const tables = await tablesService.getTables();
@@ -402,16 +421,42 @@ async function loadMessages() {
console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id);
isLoadingMessages.value = true;
try {
// Загружаем только публичные сообщения этого пользователя с пагинацией
const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 });
console.log('[ContactDetailsView] 📩 Loaded messages:', response.messages?.length || 0, 'for', contact.value.id);
// Проверяем, является ли контакт собственным ID пользователя
const isOwnContact = currentUserId.value && contact.value.id == currentUserId.value;
if (response.success && response.messages) {
messages.value = response.messages;
let allMessages = [];
if (isOwnContact) {
// Для собственного ID загружаем И личные сообщения с ИИ, И публичные сообщения от других пользователей
console.log('[ContactDetailsView] 🔍 Loading personal chat with AI + public messages for own ID:', contact.value.id);
// Загружаем личные сообщения с ИИ
const personalResponse = await getPersonalChatHistory({ limit: 50, offset: 0 });
if (personalResponse.success && personalResponse.messages) {
allMessages = [...allMessages, ...personalResponse.messages];
}
// Загружаем публичные сообщения от других пользователей (входящие)
const publicResponse = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 });
if (publicResponse.success && publicResponse.messages) {
allMessages = [...allMessages, ...publicResponse.messages];
}
// Сортируем по времени создания
allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
} else {
messages.value = [];
// Для других пользователей загружаем публичные сообщения между текущим пользователем и выбранным контактом
console.log('[ContactDetailsView] 🔍 Loading public messages between current user and contact:', contact.value.id);
const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 });
if (response.success && response.messages) {
allMessages = response.messages;
}
}
console.log('[ContactDetailsView] 📩 Loaded messages:', allMessages.length, 'for', contact.value.id);
messages.value = allMessages;
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else {
@@ -487,27 +532,23 @@ async function handleSendMessage({ message, attachments }) {
return;
}
try {
const result = await messagesService.broadcastMessage({
userId: contact.value.id,
message,
attachments
const result = await sendMessage({
recipientId: contact.value.id,
content: message,
messageType: 'public'
});
// Формируем текст результата для отображения админу
let resultText = '';
if (result && Array.isArray(result.results)) {
resultText = 'Результат рассылки по каналам:';
for (const r of result.results) {
resultText += `\n${r.channel}: ${(r.status === 'sent' || r.status === 'saved') ? 'Успех' : 'Ошибка'}${r.error ? ' (' + r.error + ')' : ''}`;
if (result && result.success) {
// Очищаем поле ввода после успешной отправки
chatNewMessage.value = '';
// Обновляем список сообщений
await loadMessages();
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Сообщение отправлено успешно', 'Успех', { type: 'success' });
}
} else {
resultText = 'Не удалось получить подробный ответ от сервера.';
throw new Error(result?.message || 'Неизвестная ошибка');
}
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert(resultText, 'Результат рассылки', { type: 'info' });
} else {
console.log('Результат рассылки:', resultText);
}
await loadMessages();
} catch (e) {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' });
@@ -701,23 +742,28 @@ watch(userId, async () => {
<style scoped>
.contact-details-page {
padding: 32px 0;
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.contact-details-content {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
padding: 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.contact-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
flex-shrink: 0;
}
.close-btn {
background: none;
@@ -730,8 +776,14 @@ watch(userId, async () => {
.close-btn:hover {
color: #333;
}
.contact-info-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.contact-info-block {
margin-bottom: 18px;
font-size: 1.08rem;
line-height: 1.7;
}
@@ -752,6 +804,7 @@ watch(userId, async () => {
display: flex;
gap: 12px;
margin-top: 18px;
flex-shrink: 0;
}
.delete-history-btn {
@@ -858,6 +911,11 @@ watch(userId, async () => {
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
flex: 1;
display: flex;
flex-direction: column;
min-height: 500px;
max-height: 70vh;
}
.messages-list {
max-height: 350px;