This commit is contained in:
2026-02-19 21:39:17 +05:00
parent 2df88ad168
commit 99b968fcbf
7 changed files with 1993 additions and 3 deletions

817
public/chat-ui.js Normal file
View File

@@ -0,0 +1,817 @@
// 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));
});

View File

@@ -449,6 +449,7 @@
<script src="main.js"></script>
<script src="tasks_files.js"></script>
<script src="navbar.js"></script>
<script src="chat-ui.js"></script>
</body>
</html>