Fix: подключение ChatComposer.js и очистка шаблона
This commit is contained in:
parent
59f90172f7
commit
1e365accf0
@ -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
|
||||
|
||||
0
backend/static/js/chat-detail-init.js
Normal file
0
backend/static/js/chat-detail-init.js
Normal file
230
backend/static/js/components/chatComposer.js
Normal file
230
backend/static/js/components/chatComposer.js
Normal 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;
|
||||
0
backend/static/js/components/chatContextMenu.js
Normal file
0
backend/static/js/components/chatContextMenu.js
Normal file
0
backend/static/js/components/chatFileUpload.js
Normal file
0
backend/static/js/components/chatFileUpload.js
Normal 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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user