Fix: подключение ChatComposer.js и очистка шаблона

This commit is contained in:
admin i 2025-11-28 10:09:23 +03:00
parent 59f90172f7
commit 1e365accf0
6 changed files with 252 additions and 242 deletions

View File

@ -118,14 +118,10 @@ class ChatListView(LoginRequiredMixin, ListView):
return qs
class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
class ChatDetailView(LoginRequiredMixin, DetailView):
model = Chat
template_name = "communications/chat_detail.html"
context_object_name = "chat"
form_class = MessageForm
def get_success_url(self):
return reverse("communications:chat_detail", kwargs={"pk": self.object.pk})
def _user_has_access(self, chat: Chat, user) -> bool:
if chat.type == "global":
@ -157,7 +153,6 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
context["messages"] = chat.messages.select_related("author").order_by(
"created_at"
)
context["form"] = context.get("form", MessageForm())
context["participants"] = chat.get_participants
# отдаём клиенту last_read_at и «первое непрочитанное»
@ -190,40 +185,6 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = request.user
if not ChatDetailView._user_has_access(self, self.object, user):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("forbidden")
form = self.get_form()
if form.is_valid():
msg = form.save(commit=False)
msg.chat = self.object
msg.author = user
msg.save()
# автору сразу «прочитано» — делаем безопасно
ts = msg.created_at
updated = ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(
chat=self.object, user=user, last_read_at=ts
)
except IntegrityError:
ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return redirect(self.get_success_url())
return self.render_to_response(self.get_context_data(form=form))
def start_private_chat(request, employee_pk):
from employees.models import Employee

View File

View File

@ -0,0 +1,230 @@
/**
* ChatComposer.js
* Отвечает за логику ввода текста, выбора файлов и отправки сообщений.
* Полностью заменяет стандартную отправку формы.
*/
class ChatComposer {
constructor(config) {
this.chatId = config.chatId;
this.apiEndpoint = config.apiEndpoint || '/api/v1/communications/upload-message/';
this.csrfToken = this.getCookie('csrftoken');
// DOM Elements
this.form = document.getElementById('chatForm');
this.textarea = document.getElementById('id_content');
this.previewContainer = document.getElementById('attachmentPreview');
// File Inputs
this.inputs = {
document: document.getElementById('documentInput'),
image: document.getElementById('imageInput'),
camera: document.getElementById('cameraInput'),
audio: document.getElementById('audioInput')
};
// Buttons
this.buttons = {
document: document.getElementById('attachDocument'),
image: document.getElementById('attachImage'),
camera: document.getElementById('attachCamera'),
audio: document.getElementById('attachAudio'),
send: this.form?.querySelector('.btn-send')
};
// State
this.selectedFiles = [];
this.isSubmitting = false;
this.init();
}
init() {
if (!this.form) {
console.error('ChatComposer: Form not found');
return;
}
this.bindEvents();
console.log('ChatComposer: Initialized');
}
bindEvents() {
// 1. Блокировка стандартной отправки
this.form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
this.handleSubmit();
});
// 2. Обработка кнопок вложений
if (this.buttons.document) this.buttons.document.addEventListener('click', (e) => { e.preventDefault(); this.inputs.document.click(); });
if (this.buttons.image) this.buttons.image.addEventListener('click', (e) => { e.preventDefault(); this.inputs.image.click(); });
if (this.buttons.camera) this.buttons.camera.addEventListener('click', (e) => { e.preventDefault(); this.inputs.camera.click(); });
if (this.buttons.audio) this.buttons.audio.addEventListener('click', (e) => { e.preventDefault(); this.inputs.audio.click(); });
// 3. Обработка выбора файлов (change event)
Object.values(this.inputs).forEach(input => {
if (input) {
input.addEventListener('change', (e) => this.handleFileSelect(e.target.files));
}
});
// 4. Enter для отправки (если не Shift+Enter)
this.textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.form.dispatchEvent(new Event('submit'));
}
});
}
handleFileSelect(files) {
if (!files || files.length === 0) return;
// Добавляем новые файлы к существующим
this.selectedFiles = [...this.selectedFiles, ...Array.from(files)];
// Очищаем input, чтобы можно было выбрать тот же файл снова
Object.values(this.inputs).forEach(input => { if(input) input.value = ''; });
this.updatePreview();
}
removeFile(index) {
this.selectedFiles.splice(index, 1);
this.updatePreview();
}
updatePreview() {
if (!this.previewContainer) return;
if (this.selectedFiles.length === 0) {
this.previewContainer.classList.add('d-none');
this.previewContainer.innerHTML = '';
return;
}
this.previewContainer.classList.remove('d-none');
this.previewContainer.innerHTML = '';
this.selectedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item d-flex align-items-center gap-2 p-2 border rounded mb-2 bg-white';
let icon = 'bi-file-earmark';
if (file.type.startsWith('image/')) icon = 'bi-image';
else if (file.type.startsWith('video/')) icon = 'bi-camera-video';
else if (file.type.startsWith('audio/')) icon = 'bi-music-note';
item.innerHTML = `
<i class="${icon} fs-4 text-primary"></i>
<div class="flex-grow-1 text-truncate">
<div class="fw-semibold text-truncate">${file.name}</div>
<div class="small text-secondary">${this.formatFileSize(file.size)}</div>
</div>
<button type="button" class="btn btn-sm btn-ghost text-danger remove-file-btn">
<i class="bi-x-lg"></i>
</button>
`;
item.querySelector('.remove-file-btn').addEventListener('click', () => this.removeFile(index));
this.previewContainer.appendChild(item);
});
}
async handleSubmit() {
if (this.isSubmitting) return;
const content = this.textarea.value.trim();
// Валидация
if (!content && this.selectedFiles.length === 0) {
// Можно добавить визуальный эффект ошибки
this.textarea.focus();
return;
}
this.isSubmitting = true;
this.toggleLoading(true);
try {
const formData = new FormData();
formData.append('chat_id', this.chatId);
formData.append('content', content); // Отправляем даже пустой текст
// Добавляем файлы
this.selectedFiles.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
console.log(`ChatComposer: Sending message. Content len: ${content.length}, Files: ${this.selectedFiles.length}`);
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'X-CSRFToken': this.csrfToken
},
body: formData
});
const result = await response.json();
if (response.ok && result.ok) {
this.resetForm();
} else {
console.error('ChatComposer: Server error', result);
alert('Ошибка отправки: ' + (result.error || 'Неизвестная ошибка'));
}
} catch (error) {
console.error('ChatComposer: Network error', error);
alert('Ошибка сети. Проверьте подключение.');
} finally {
this.isSubmitting = false;
this.toggleLoading(false);
this.textarea.focus();
}
}
resetForm() {
this.textarea.value = '';
this.selectedFiles = [];
this.updatePreview();
// Сброс высоты textarea (если используется авто-высота)
this.textarea.style.height = 'auto';
}
toggleLoading(isLoading) {
if (this.buttons.send) {
this.buttons.send.disabled = isLoading;
this.buttons.send.innerHTML = isLoading
? '<span class="spinner-border spinner-border-sm"></span>'
: '<i class="bi-send-fill"></i>';
}
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
}
// Экспорт для использования
window.ChatComposer = ChatComposer;

View File

@ -148,7 +148,7 @@
<!-- Композер -->
<div class="chat-composer">
<div id="typing" class="small text-secondary ps-1 pt-1 d-none">Собеседник печатает…</div>
<form id="chatForm" class="composer-wrap">
<form id="chatForm" class="composer-wrap" action="javascript:void(0);" onsubmit="return false;">
{% csrf_token %}
<div class="composer-input-group">
<!-- Кнопка вложений с выпадающим меню -->
@ -219,212 +219,31 @@
</div>
</div>
{% block extra_js %}
<script src="{% static 'js/components/chatAvatarMap.js' %}"></script>
<script src="{% static 'js/components/chatMessageTemplates.js' %}"></script>
<script src="{% static 'js/components/chatWebSocket.js' %}"></script>
<script src="{% static 'js/components/chatComposer.js' %}"></script>
<script src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<script src="{% static 'js/components/textareaAutogrow.js' %}"></script>
<script>
// Функция для получения CSRF токена из cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
// Инициализация компонентов
document.addEventListener('DOMContentLoaded', function() {
// 1. Авто-высота поля ввода
if (window.initTextareaAutogrow) {
window.initTextareaAutogrow();
}
}
return cookieValue;
}
// Обработка вложений
document.addEventListener('DOMContentLoaded', function() {
const attachDocument = document.getElementById('attachDocument');
const attachImage = document.getElementById('attachImage');
const attachCamera = document.getElementById('attachCamera');
const attachAudio = document.getElementById('attachAudio');
const documentInput = document.getElementById('documentInput');
const imageInput = document.getElementById('imageInput');
const cameraInput = document.getElementById('cameraInput');
const audioInput = document.getElementById('audioInput');
const attachmentPreview = document.getElementById('attachmentPreview');
const chatForm = document.getElementById('chatForm');
// Массив для хранения выбранных файлов
let selectedFiles = [];
// Обработчики кликов на пункты меню
attachDocument?.addEventListener('click', (e) => {
e.preventDefault();
documentInput.click();
});
attachImage?.addEventListener('click', (e) => {
e.preventDefault();
imageInput.click();
});
attachCamera?.addEventListener('click', (e) => {
e.preventDefault();
cameraInput.click();
});
attachAudio?.addEventListener('click', (e) => {
e.preventDefault();
audioInput.click();
});
// Обработка выбора файлов
function handleFileSelect(files) {
if (!files || files.length === 0) return;
// Добавляем новые файлы к существующим
selectedFiles = [...selectedFiles, ...Array.from(files)];
updatePreview();
}
function updatePreview() {
if (selectedFiles.length === 0) {
attachmentPreview.classList.add('d-none');
attachmentPreview.innerHTML = '';
return;
}
attachmentPreview.classList.remove('d-none');
attachmentPreview.innerHTML = '';
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'attachment-item d-flex align-items-center gap-2 p-2 border rounded mb-2';
// Иконка в зависимости от типа файла
let icon = 'bi-file-earmark';
if (file.type.startsWith('image/')) icon = 'bi-image';
else if (file.type.startsWith('video/')) icon = 'bi-camera-video';
else if (file.type.startsWith('audio/')) icon = 'bi-music-note';
else if (file.type.includes('pdf')) icon = 'bi-file-pdf';
fileItem.innerHTML = `
<i class="${icon} fs-4 text-primary"></i>
<div class="flex-grow-1">
<div class="fw-semibold">${file.name}</div>
<div class="small text-secondary">${formatFileSize(file.size)}</div>
</div>
<button type="button" class="btn btn-sm btn-ghost" data-remove-index="${index}">
<i class="bi-x-lg"></i>
</button>
`;
// Обработчик удаления конкретного файла
const removeBtn = fileItem.querySelector('[data-remove-index]');
removeBtn.addEventListener('click', () => removeAttachment(index));
attachmentPreview.appendChild(fileItem);
});
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function removeAttachment(index) {
selectedFiles.splice(index, 1);
updatePreview();
}
documentInput?.addEventListener('change', (e) => handleFileSelect(e.target.files));
imageInput?.addEventListener('change', (e) => handleFileSelect(e.target.files));
cameraInput?.addEventListener('change', (e) => handleFileSelect(e.target.files));
audioInput?.addEventListener('change', (e) => handleFileSelect(e.target.files));
// Обработка отправки формы с файлами
chatForm?.addEventListener('submit', async function(e) {
e.preventDefault();
// Получаем FormData из самой формы для захвата всех данных
const formDataFromForm = new FormData(chatForm);
const contentFromForm = formDataFromForm.get('content') || '';
const content = contentFromForm.trim();
console.log('DEBUG: content from FormData:', content);
console.log('DEBUG: content length:', content.length);
console.log('DEBUG: selectedFiles.length:', selectedFiles.length);
// Проверка: должен быть либо текст, либо файлы
if (!content && selectedFiles.length === 0) {
alert('Введите текст сообщения или прикрепите файл');
return;
}
// Всегда отправляем через API (с файлами или без)
const formData = new FormData();
formData.append('content', content);
selectedFiles.forEach((file, index) => {
formData.append(`file_${index}`, file);
console.log(`DEBUG: Adding file ${index}:`, file.name, file.size);
});
formData.append('chat_id', '{{ chat.id }}');
// Выводим все что в FormData
console.log('DEBUG: FormData содержит:');
for (let [key, value] of formData.entries()) {
if (value instanceof File) {
console.log(` ${key}: [File] ${value.name} (${value.size} bytes)`);
} else {
console.log(` ${key}: ${value}`);
}
}
const csrftoken = getCookie('csrftoken');
console.log('CSRF token:', csrftoken);
if (!csrftoken) {
console.error('CSRF token not found in cookies!');
alert('Ошибка: CSRF токен не найден. Обновите страницу.');
return;
}
try {
const response = await fetch('/api/v1/communications/upload-message/', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrftoken
}
// 2. Композер сообщений (отправка текста и файлов)
if (window.ChatComposer) {
const composer = new ChatComposer({
chatId: '{{ chat.id }}',
apiEndpoint: '/api/v1/communications/upload-message/'
});
const result = await response.json();
if (result.ok) {
console.log('Сообщение отправлено:', result);
// Очищаем форму
const textarea = document.getElementById('id_content');
if (textarea) textarea.value = '';
selectedFiles = [];
updatePreview();
if (documentInput) documentInput.value = '';
if (imageInput) imageInput.value = '';
if (cameraInput) cameraInput.value = '';
if (audioInput) audioInput.value = '';
// Отображаем сообщение через WebSocket будет автоматически
} else {
console.error('Ошибка отправки:', result.error);
alert('Ошибка при отправке: ' + result.error);
}
} catch (error) {
console.error('Ошибка отправки:', error);
alert('Ошибка сети при отправке сообщения');
} else {
console.error('ChatComposer not loaded');
}
});
});
</script>
{% endblock %}