Files
minicrm/public/chat-ui.js
2026-03-05 22:58:14 +05:00

817 lines
29 KiB
JavaScript
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.
// chat-ui.js - Клиентская часть для чата задач
class TaskChat {
constructor(taskId, taskTitle) {
this.taskId = taskId;
this.taskTitle = taskTitle;
this.messages = [];
this.currentUserId = null;
this.isLoading = false;
this.hasMore = true;
this.lastMessageDate = null;
this.replyToMessage = null;
this.autoRefreshInterval = null;
this.init();
}
async init() {
await this.loadCurrentUser();
this.createChatModal();
this.loadMessages();
this.setupAutoRefresh();
this.setupEventListeners();
}
async loadCurrentUser() {
try {
const response = await fetch('/api/user');
const data = await response.json();
this.currentUserId = data.user.id;
} catch (error) {
console.error('Ошибка загрузки пользователя:', error);
}
}
createChatModal() {
const modalHtml = `
<div class="modal" id="task-chat-modal-${this.taskId}">
<div class="modal-content chat-modal-content">
<div class="modal-header chat-header">
<div class="chat-header-actions">
<span class="chat-unread-badge" id="chat-unread-${this.taskId}" style="display: none;">0</span>
<button class="chat-refresh-btn" onclick="window.taskChats[${this.taskId}].refreshMessages()" title="Обновить">🔄</button>
<span class="close" onclick="window.taskChats[${this.taskId}].close()">&times;</span>
</div>
<h3>
<span class="chat-icon">💬</span>
Чат задачи №${this.taskId}: "${this.taskTitle}"
</h3>
<h3>
<span class="chat-icon"> </span>
</h3>
</div>
<div class="modal-body chat-body" style="padding: 0; display: flex; flex-direction: column; height: 500px;">
<div class="chat-messages-container" id="chat-messages-${this.taskId}">
<div class="chat-messages" id="chat-messages-list-${this.taskId}">
<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>
</div>
</div>
<div class="chat-reply-info" id="chat-reply-info-${this.taskId}" style="display: none;">
<span>Ответ на сообщение <span id="reply-to-text-${this.taskId}"></span></span>
<button class="chat-cancel-reply" onclick="window.taskChats[${this.taskId}].cancelReply()">✕</button>
</div>
<div class="chat-input-area">
<textarea
class="chat-input"
id="chat-input-${this.taskId}"
placeholder="Напишите сообщение..."
rows="1"
></textarea>
<div class="chat-attachments" id="chat-attachments-${this.taskId}"></div>
<div class="chat-actions">
<button class="chat-send-btn" onclick="window.taskChats[${this.taskId}].sendMessage()">➤ Отправить ➤</button>
</div>
</div>
</div>
</div>
</div>
`;
// Добавляем стили для чата
this.addChatStyles();
// Добавляем модальное окно в DOM
const modalContainer = document.createElement('div');
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer);
// Показываем модальное окно
setTimeout(() => {
const modal = document.getElementById(`task-chat-modal-${this.taskId}`);
modal.style.display = 'block';
// Фокус на поле ввода
document.getElementById(`chat-input-${this.taskId}`).focus();
// Настройка авто-изменения высоты textarea
this.setupTextareaAutoResize();
}, 10);
}
addChatStyles() {
if (document.getElementById('chat-styles')) return;
const styles = `
<style id="chat-styles">
.chat-modal-content {
max-width: 800px;
width: 90%;
height: 80vh;
display: flex;
flex-direction: column;
padding: 0 !important;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.chat-header h3 {
margin: 0;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.chat-icon {
font-size: 20px;
}
.chat-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.chat-unread-badge {
background-color: #ff4444;
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 12px;
min-width: 20px;
text-align: center;
}
.chat-refresh-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px 8px; /* Увеличили область клика */
border-radius: 4px; /* Скругленные углы при наведении */
transition: background-color 0.2s; /* Плавный переход цвета */
line-height: 1; /* Фиксируем высоту строки */
}
.chat-refresh-btn:hover {
background-color: rgba(0,0,0,0.05); /* Легкий серый фон при наведении */
opacity: 1; /* Убираем прозрачность или оставляем как есть */
}
.chat-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.chat-messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f5f5f5;
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-message {
max-width: 80%;
padding: 10px 15px;
border-radius: 15px;
position: relative;
word-wrap: break-word;
}
.chat-message-own {
align-self: flex-end;
background-color: #007bff;
color: white;
border-bottom-right-radius: 5px;
}
.chat-message-other {
align-self: flex-start;
background-color: white;
color: #333;
border-bottom-left-radius: 5px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.chat-message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-size: 12px;
}
.chat-message-own .chat-message-header {
color: rgba(255,255,255,0.8);
}
.chat-message-other .chat-message-header {
color: #666;
}
.chat-message-author {
font-weight: bold;
}
.chat-message-time {
font-size: 11px;
}
.chat-message-edited {
font-size: 11px;
font-style: italic;
margin-left: 5px;
}
.chat-message-text {
font-size: 14px;
line-height: 1.4;
}
.chat-message-files {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-file {
background-color: rgba(0,0,0,0.05);
border-radius: 5px;
padding: 5px 10px;
font-size: 12px;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.chat-message-own .chat-file {
background-color: rgba(255,255,255,0.2);
}
.chat-file:hover {
background-color: rgba(0,0,0,0.1);
}
.chat-reply-info {
padding: 10px 20px;
background-color: #e9ecef;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.chat-cancel-reply {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
}
.chat-input-area {
border-top: 1px solid #dee2e6;
padding: 15px 20px;
background-color: white;
}
.chat-input {
width: 100%;
border: 1px solid #ced4da;
border-radius: 20px;
padding: 10px 15px;
font-size: 14px;
resize: none;
outline: none;
}
.chat-input:focus {
border-color: #007bff;
}
.chat-attachments {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-attachment {
background-color: #e9ecef;
border-radius: 15px;
padding: 5px 10px;
font-size: 12px;
display: flex;
align-items: center;
gap: 5px;
}
.chat-remove-attachment {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #666;
}
.chat-actions {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-attach-btn {
cursor: pointer;
font-size: 20px;
padding: 5px;
}
.chat-send-btn {
background-color: #007bff;
color: white;
border: none;
width: 100%;
cursor: pointer;
font-size: 18px;
}
.chat-send-btn:hover {
background-color: #0056b3;
}
.chat-loading {
text-align: center;
padding: 20px;
color: #666;
}
.chat-no-messages {
text-align: center;
padding: 40px 20px;
color: #999;
font-style: italic;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
setupTextareaAutoResize() {
const textarea = document.getElementById(`chat-input-${this.taskId}`);
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
}
setupEventListeners() {
// Отправка по Enter (но не с Shift)
const textarea = document.getElementById(`chat-input-${this.taskId}`);
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
// Загрузка файлов
const fileInput = document.getElementById(`chat-file-input-${this.taskId}`);
fileInput.addEventListener('change', (e) => {
this.handleFileSelect(e.target.files);
});
// Бесконечная прокрутка для загрузки старых сообщений
const messagesContainer = document.getElementById(`chat-messages-${this.taskId}`);
messagesContainer.addEventListener('scroll', () => {
if (messagesContainer.scrollTop === 0 && !this.isLoading && this.hasMore) {
this.loadMoreMessages();
}
});
}
handleFileSelect(files) {
const attachmentsContainer = document.getElementById(`chat-attachments-${this.taskId}`);
attachmentsContainer.innerHTML = '';
this.selectedFiles = Array.from(files);
this.selectedFiles.forEach((file, index) => {
const attachment = document.createElement('div');
attachment.className = 'chat-attachment';
attachment.innerHTML = `
📎 ${file.name} (${this.formatFileSize(file.size)})
<button class="chat-remove-attachment" onclick="window.taskChats[${this.taskId}].removeAttachment(${index})">✕</button>
`;
attachmentsContainer.appendChild(attachment);
});
}
removeAttachment(index) {
if (this.selectedFiles) {
this.selectedFiles.splice(index, 1);
this.handleFileSelect(this.selectedFiles);
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async loadMessages(before = null) {
if (this.isLoading) return;
this.isLoading = true;
const loadingEl = document.querySelector(`#chat-messages-list-${this.taskId} .chat-loading`);
if (loadingEl) loadingEl.style.display = 'block';
try {
let url = `/api/chat/tasks/${this.taskId}/messages?limit=30`;
if (before) {
url += `&before=${before}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.messages && data.messages.length > 0) {
if (before) {
// Добавляем старые сообщения в начало
this.messages = [...data.messages.reverse(), ...this.messages];
} else {
// Первая загрузка
this.messages = data.messages.reverse();
}
this.hasMore = data.hasMore;
this.renderMessages();
if (!before) {
// Прокручиваем вниз при первой загрузке
this.scrollToBottom();
} else {
// Сохраняем позицию прокрутки при загрузке старых сообщений
const container = document.getElementById(`chat-messages-${this.taskId}`);
const oldHeight = container.scrollHeight;
setTimeout(() => {
container.scrollTop = container.scrollHeight - oldHeight;
}, 10);
}
} else {
if (!before) {
this.renderEmpty();
}
this.hasMore = false;
}
} catch (error) {
console.error('Ошибка загрузки сообщений:', error);
} finally {
this.isLoading = false;
if (loadingEl) loadingEl.style.display = 'none';
}
}
async loadMoreMessages() {
if (this.messages.length > 0) {
const oldestMessage = this.messages[0];
await this.loadMessages(oldestMessage.created_at);
}
}
renderMessages() {
const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`);
messagesList.innerHTML = '<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>';
if (this.messages.length === 0) {
this.renderEmpty();
return;
}
this.messages.forEach(message => {
const messageEl = this.createMessageElement(message);
messagesList.appendChild(messageEl);
});
}
createMessageElement(message) {
const isOwn = message.user_id === this.currentUserId;
const div = document.createElement('div');
div.className = `chat-message ${isOwn ? 'chat-message-own' : 'chat-message-other'}`;
div.dataset.messageId = message.id;
const time = new Date(message.created_at).toLocaleString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit'
});
let replyHtml = '';
if (message.reply_to_message) {
replyHtml = `
<div class="chat-message-reply">
↪ Ответ ${message.reply_to_user_name}: "${message.reply_to_message.substring(0, 30)}${message.reply_to_message.length > 30 ? '...' : ''}"
</div>
`;
}
let filesHtml = '';
if (message.files && message.files.length > 0) {
filesHtml = '<div class="chat-message-files">';
message.files.forEach(file => {
filesHtml += `
<div class="chat-file" onclick="window.taskChats[${this.taskId}].downloadFile(${file.id})">
📎 ${file.original_name} (${this.formatFileSize(file.file_size)})
</div>
`;
});
filesHtml += '</div>';
}
let actionsHtml = '';
if (isOwn || window.currentUserRole === 'admin') {
actionsHtml = `
<div class="chat-message-actions">
${isOwn ? `<button onclick="window.taskChats[${this.taskId}].editMessage(${message.id})" title="Редактировать">✎</button>` : ''}
<button onclick="window.taskChats[${this.taskId}].deleteMessage(${message.id})" title="Удалить">🗑️</button>
<!-- <button onclick="window.taskChats[${this.taskId}].replyToMessage(${message.id}, '${message.user_name.replace(/'/g, "\\'")}', '${message.message.replace(/'/g, "\\'").substring(0, 30)}')" title="Ответить">↩</button> -->
</div>
`;
}
div.innerHTML = `
${replyHtml}
<div class="chat-message-header">
<span class="chat-message-author">${message.user_name}</span>
<span class="chat-message-time">${time}</span>
${message.is_edited ? '<span class="chat-message-edited">(ред.)</span>' : ''}
${actionsHtml}
</div>
<div class="chat-message-text">${this.escapeHtml(message.message)}</div>
${filesHtml}
`;
return div;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
renderEmpty() {
const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`);
messagesList.innerHTML = `
<div class="chat-no-messages">
💬 Нет сообщений. Напишите что-нибудь...
</div>
`;
}
async sendMessage() {
const input = document.getElementById(`chat-input-${this.taskId}`);
const message = input.value.trim();
if (!message && (!this.selectedFiles || this.selectedFiles.length === 0)) {
return;
}
const formData = new FormData();
formData.append('message', message);
if (this.replyToMessage) {
formData.append('reply_to_id', this.replyToMessage.id);
}
if (this.selectedFiles && this.selectedFiles.length > 0) {
this.selectedFiles.forEach(file => {
formData.append('files', file);
});
}
try {
const response = await fetch(`/api/chat/tasks/${this.taskId}/messages`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
input.value = '';
input.style.height = 'auto';
this.selectedFiles = [];
document.getElementById(`chat-attachments-${this.taskId}`).innerHTML = '';
this.cancelReply();
// Добавляем новое сообщение
this.messages.push(data.message);
this.renderMessages();
this.scrollToBottom();
}
} catch (error) {
console.error('Ошибка отправки сообщения:', error);
alert('Ошибка отправки сообщения');
}
}
async editMessage(messageId) {
const message = this.messages.find(m => m.id === messageId);
if (!message) return;
const newText = prompt('Редактировать сообщение:', message.message);
if (newText && newText.trim() !== message.message) {
try {
const response = await fetch(`/api/chat/messages/${messageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ message: newText.trim() })
});
const data = await response.json();
if (data.success) {
message.message = newText.trim();
message.is_edited = true;
this.renderMessages();
}
} catch (error) {
console.error('Ошибка редактирования:', error);
alert('Ошибка редактирования сообщения');
}
}
}
async deleteMessage(messageId) {
if (!confirm('Удалить это сообщение?')) return;
try {
const response = await fetch(`/api/chat/messages/${messageId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
this.messages = this.messages.filter(m => m.id !== messageId);
this.renderMessages();
}
} catch (error) {
console.error('Ошибка удаления:', error);
alert('Ошибка удаления сообщения');
}
}
replyToMessage(messageId, userName, messagePreview) {
this.replyToMessage = {
id: messageId,
userName: userName,
preview: messagePreview
};
const replyInfo = document.getElementById(`chat-reply-info-${this.taskId}`);
document.getElementById(`reply-to-text-${this.taskId}`).textContent =
`${userName}: "${messagePreview}${messagePreview.length > 30 ? '...' : ''}"`;
replyInfo.style.display = 'flex';
document.getElementById(`chat-input-${this.taskId}`).focus();
}
cancelReply() {
this.replyToMessage = null;
document.getElementById(`chat-reply-info-${this.taskId}`).style.display = 'none';
}
async downloadFile(fileId) {
window.open(`/api/chat/files/${fileId}/download`, '_blank');
}
scrollToBottom() {
const container = document.getElementById(`chat-messages-${this.taskId}`);
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 10);
}
setupAutoRefresh() {
// Обновляем непрочитанные каждые 10 секунд
this.autoRefreshInterval = setInterval(() => {
this.updateUnreadCount();
}, 10000);
}
async updateUnreadCount() {
try {
const response = await fetch(`/api/chat/tasks/${this.taskId}/unread-count`);
const data = await response.json();
const badge = document.getElementById(`chat-unread-${this.taskId}`);
if (data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
} catch (error) {
console.error('Ошибка обновления непрочитанных:', error);
}
}
async markAllAsRead() {
try {
await fetch(`/api/chat/tasks/${this.taskId}/mark-read`, {
method: 'POST'
});
document.getElementById(`chat-unread-${this.taskId}`).style.display = 'none';
} catch (error) {
console.error('Ошибка отметки прочитанных:', error);
}
}
refreshMessages() {
this.messages = [];
this.loadMessages();
this.markAllAsRead();
}
close() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
}
const modal = document.getElementById(`task-chat-modal-${this.taskId}`);
if (modal) {
modal.style.display = 'none';
setTimeout(() => {
modal.parentElement.remove();
}, 300);
}
}
}
// Глобальный объект для хранения экземпляров чатов
window.taskChats = window.taskChats || {};
// Функция для открытия чата (заменяет существующую openTaskChat)
function openTaskChat(taskId) {
// Находим задачу
const task = window.tasks?.find(t => t.id === taskId);
// Если уже есть открытый чат для этой задачи, просто показываем его
if (window.taskChats[taskId]) {
const existingModal = document.getElementById(`task-chat-modal-${taskId}`);
if (existingModal) {
existingModal.style.display = 'block';
window.taskChats[taskId].refreshMessages();
return;
}
}
// Создаем новый экземпляр чата
window.taskChats[taskId] = new TaskChat(taskId, task ? task.title : `Задача #${taskId}`);
}
// Функция для закрытия чата
function closeTaskChat(taskId) {
if (window.taskChats[taskId]) {
window.taskChats[taskId].close();
delete window.taskChats[taskId];
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
// Получаем роль текущего пользователя для прав доступа
fetch('/api/user')
.then(response => response.json())
.then(data => {
window.currentUserRole = data.user.role;
})
.catch(error => console.error('Ошибка загрузки пользователя:', error));
});