feat(avatar): implement avatar cropper with FaceID requirements
Implemented avatar cropping component for FaceID system integration. Features: - Image cropper using Cropper.js library - FaceID requirements validation (70-80% face coverage, 600x600px min) - Square crop area (1:1 aspect ratio) - Output: 800x800px JPEG at 92% quality - Real-time size indicator - Integrated in 2 places: registration + profile editor Components: - avatarCropperHandler.js: Main cropper module with AvatarCropper class - Modal UI with Bootstrap 5 - FaceID instructions and guidelines - File validation (size, format, dimensions) Integration: - auth/register.html: Registration form with cropper - employees/employee_form_personal.html: Profile editor with cropper - employees/employee_form_scripts.html: Scripts updated Technical details: - CDN: Cropper.js v1.6.1 - Min input size: 600x600px - Max file size: 10 MB - Formats: JPG, PNG, GIF - Output format: JPEG (optimized for face recognition) Validation: ✓ File format validation ✓ File size validation (10 MB max) ✓ Image dimensions validation (600x600px min) ✓ Real-time crop size indicator ✓ Error handling with user-friendly messages UI/UX: - Modal window with instructions - Live preview of cropped image - Size indicator (green/red based on requirements) - Clear FaceID guidelines for users - Responsive layout
This commit is contained in:
parent
3ab23973b5
commit
d6e974746e
330
AVATAR_CROPPER_IMPLEMENTATION.md
Normal file
330
AVATAR_CROPPER_IMPLEMENTATION.md
Normal file
@ -0,0 +1,330 @@
|
||||
# Avatar Cropper Implementation for FaceID
|
||||
|
||||
## 📋 Описание
|
||||
|
||||
Реализован компонент обрезки аватара с требованиями для системы распознавания лиц (FaceID).
|
||||
|
||||
## 🎯 Цели
|
||||
|
||||
1. ✅ Обеспечить единообразие фотографий для FaceID
|
||||
2. ✅ Гарантировать, что лицо занимает 70-80% кадра
|
||||
3. ✅ Минимальный размер: 600x600px
|
||||
4. ✅ Формат: JPEG с оптимизацией качества (92%)
|
||||
5. ✅ Интеграция в 2 места: регистрация + редактор профиля
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Используемые технологии
|
||||
|
||||
- **Cropper.js v1.6.1** - библиотека для обрезки изображений
|
||||
- **Bootstrap 5 Modal** - модальное окно
|
||||
- **Canvas API** - конвертация в нужный формат
|
||||
- **File API** - работа с файлами
|
||||
|
||||
### Компоненты
|
||||
|
||||
#### 1. **avatarCropperHandler.js**
|
||||
Основной модуль с классом `AvatarCropper`:
|
||||
|
||||
```javascript
|
||||
import { initAvatarCropper } from '/static/js/components/avatarCropperHandler.js';
|
||||
|
||||
// Инициализация
|
||||
initAvatarCropper({
|
||||
fileInputSelector: 'input[type="file"][name="avatar"]',
|
||||
previewImageId: 'avatarPreview',
|
||||
placeholderId: 'avatarPlaceholder',
|
||||
modalId: 'avatarCropperModal'
|
||||
});
|
||||
```
|
||||
|
||||
**Возможности:**
|
||||
- ✅ Автоматическое открытие cropper при выборе файла
|
||||
- ✅ Валидация размера и формата
|
||||
- ✅ Квадратная область обрезки (1:1)
|
||||
- ✅ Минимальный размер обрезки: 200x200px
|
||||
- ✅ Выходной размер: 800x800px
|
||||
- ✅ Качество JPEG: 92%
|
||||
- ✅ Индикатор размера в реальном времени
|
||||
- ✅ Превью обрезанного изображения
|
||||
- ✅ Автоматическое обновление file input
|
||||
|
||||
### Интеграция
|
||||
|
||||
#### Место 1: Регистрация (`templates/auth/register.html`)
|
||||
|
||||
```html
|
||||
<!-- Cropper.js CDN -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initAvatarCropper } from "{% static 'js/components/avatarCropperHandler.js' %}";
|
||||
|
||||
initAvatarCropper({
|
||||
fileInputSelector: 'input[type="file"][name="avatar"]',
|
||||
previewImageId: 'avatarPreview',
|
||||
placeholderId: 'avatarPlaceholder'
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Место 2: Редактор профиля (`templates/employees/components/employee_form_scripts.html`)
|
||||
|
||||
```html
|
||||
<!-- Cropper.js CDN -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initAvatarCropper } from '{% static "js/components/avatarCropperHandler.js" %}';
|
||||
|
||||
initAvatarCropper({
|
||||
fileInputSelector: '#avatarInput',
|
||||
previewImageId: 'avatarPreview',
|
||||
modalId: 'avatarCropperModal'
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## 📝 Требования FaceID
|
||||
|
||||
### Обязательные требования:
|
||||
1. ✅ **Размер лица**: 70-80% кадра
|
||||
2. ✅ **Центрирование**: Лицо в центре рамки
|
||||
3. ✅ **Минимальный размер**: 600x600px
|
||||
4. ✅ **Соотношение сторон**: 1:1 (квадрат)
|
||||
5. ✅ **Формат**: JPEG
|
||||
6. ✅ **Качество**: 92%
|
||||
|
||||
### Рекомендации для пользователей:
|
||||
- Избегайте теней и бликов
|
||||
- Смотрите прямо в камеру
|
||||
- Нейтральное выражение лица
|
||||
- Хорошее освещение
|
||||
|
||||
## 🎨 UI/UX
|
||||
|
||||
### Модальное окно cropper
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Обрезка фото для FaceID [X] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ℹ️ Требования для системы распознавания:│
|
||||
│ • Лицо должно занимать 70-80% кадра │
|
||||
│ • Расположите лицо в центре рамки │
|
||||
│ • Избегайте теней и бликов │
|
||||
│ • Смотрите прямо в камеру │
|
||||
│ • Нейтральное выражение лица │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [Cropper Canvas Area] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Размер обрезанного фото: 800x800px │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Отмена] [✓ Применить] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Превью в форме
|
||||
|
||||
- **До выбора**: Иконка placeholder (bi-person или bi-person-circle)
|
||||
- **После обрезки**: Обрезанное изображение 120x120px
|
||||
- **Подсказка**: Текст с требованиями FaceID
|
||||
|
||||
## 🚀 Workflow
|
||||
|
||||
### Шаг 1: Пользователь выбирает файл
|
||||
```
|
||||
Input[type=file] → change event
|
||||
```
|
||||
|
||||
### Шаг 2: Валидация
|
||||
```javascript
|
||||
✓ Формат: JPG, PNG, GIF
|
||||
✓ Размер: < 10 MB
|
||||
✓ Минимальный размер: 600x600px
|
||||
```
|
||||
|
||||
### Шаг 3: Открытие Cropper
|
||||
```
|
||||
FileReader → DataURL → Modal → Cropper.js init
|
||||
```
|
||||
|
||||
### Шаг 4: Обрезка
|
||||
```
|
||||
User adjusts crop area → Real-time size indicator
|
||||
```
|
||||
|
||||
### Шаг 5: Применение
|
||||
```
|
||||
getCroppedCanvas(800x800) → Blob → File → Update input
|
||||
```
|
||||
|
||||
### Шаг 6: Превью
|
||||
```
|
||||
Canvas → DataURL → Update preview image
|
||||
```
|
||||
|
||||
### Шаг 7: Отправка формы
|
||||
```
|
||||
FormData with cropped file → Backend API
|
||||
```
|
||||
|
||||
## 📊 Конфигурация
|
||||
|
||||
### Настройки по умолчанию:
|
||||
|
||||
```javascript
|
||||
{
|
||||
// Cropper.js options
|
||||
aspectRatio: 1,
|
||||
viewMode: 2,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
minCropBoxWidth: 200,
|
||||
minCropBoxHeight: 200,
|
||||
|
||||
// Output settings
|
||||
minOutputSize: 600,
|
||||
outputSize: 800,
|
||||
outputQuality: 0.92,
|
||||
outputFormat: 'image/jpeg',
|
||||
|
||||
// Validation
|
||||
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
||||
allowedFormats: ['image/jpeg', 'image/png', 'image/gif']
|
||||
}
|
||||
```
|
||||
|
||||
### Кастомизация:
|
||||
|
||||
```javascript
|
||||
initAvatarCropper({
|
||||
// Селекторы
|
||||
fileInputSelector: '#myAvatarInput',
|
||||
previewImageId: 'myPreview',
|
||||
modalId: 'myCropperModal',
|
||||
|
||||
// Cropper опции
|
||||
cropperOptions: {
|
||||
aspectRatio: 1,
|
||||
autoCropArea: 0.9
|
||||
},
|
||||
|
||||
// Выходные параметры
|
||||
outputSize: 1024,
|
||||
outputQuality: 0.95,
|
||||
|
||||
// Тексты
|
||||
texts: {
|
||||
modalTitle: 'Обрезать фото',
|
||||
cropButton: 'Готово',
|
||||
cancelButton: 'Отмена'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Тестовые сценарии:
|
||||
|
||||
1. **Регистрация нового пользователя**
|
||||
- Открыть `/auth/register/`
|
||||
- Выбрать фото
|
||||
- Проверить открытие cropper
|
||||
- Обрезать фото
|
||||
- Отправить форму
|
||||
- Проверить, что аватар сохранился
|
||||
|
||||
2. **Редактирование профиля**
|
||||
- Открыть `/employees/profile/`
|
||||
- Выбрать новое фото
|
||||
- Обрезать фото
|
||||
- Сохранить изменения
|
||||
- Проверить обновление аватара
|
||||
|
||||
3. **Валидация**
|
||||
- Попытаться загрузить файл < 600x600px → Ошибка
|
||||
- Попытаться загрузить файл > 10MB → Ошибка
|
||||
- Попытаться загрузить .txt файл → Ошибка
|
||||
|
||||
4. **Отмена**
|
||||
- Выбрать фото
|
||||
- Открыть cropper
|
||||
- Нажать "Отмена"
|
||||
- Проверить, что input очищен
|
||||
|
||||
## 📦 Зависимости
|
||||
|
||||
### CDN (используется):
|
||||
```html
|
||||
<!-- Cropper.js CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css"
|
||||
integrity="sha512-hvNR0F/e2J7zPPfLC9auFe3/SE0yG4aJCOd/qxew74NN7eyiSKjr7xJJMu1Jy2wf7FXITpWS1E/RY8yzuXN7VA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Cropper.js JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"
|
||||
integrity="sha512-9KkIqdfN7ipEW6B6k+Aq20PV31bjODg4AA52W+tYtAE0jE0kMx49bjJ3FgvS56wzmyfMUHbQ4Km2b7l9+Y/+Eg=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
```
|
||||
|
||||
### Существующие зависимости:
|
||||
- ✅ Bootstrap 5 (для модального окна)
|
||||
- ✅ Bootstrap Icons (для иконок)
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Проблема: Cropper не открывается
|
||||
**Решение:** Проверьте, что Cropper.js загружен из CDN и Bootstrap модальные окна работают.
|
||||
|
||||
### Проблема: File input не обновляется
|
||||
**Решение:** Проверьте console на ошибки. Убедитесь, что DataTransfer API поддерживается.
|
||||
|
||||
### Проблема: Превью не обновляется
|
||||
**Решение:** Проверьте селекторы `previewImageId` и `placeholderId`.
|
||||
|
||||
### Проблема: Ошибка "Image too small"
|
||||
**Решение:** Убедитесь, что исходное изображение >= 600x600px.
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
### Файлы созданы:
|
||||
- ✅ `backend/static/js/components/avatarCropperHandler.js` (542 строки)
|
||||
- ✅ `AVATAR_CROPPER_IMPLEMENTATION.md` (этот файл)
|
||||
|
||||
### Файлы изменены:
|
||||
- ✅ `backend/templates/auth/register.html` - добавлен cropper
|
||||
- ✅ `backend/templates/employees/components/employee_form_scripts.html` - добавлен cropper
|
||||
- ✅ `backend/templates/employees/components/employee_form_personal.html` - обновлен текст подсказки
|
||||
|
||||
### Функционал:
|
||||
- ✅ Валидация размера файла (10 MB)
|
||||
- ✅ Валидация формата (JPG, PNG, GIF)
|
||||
- ✅ Валидация минимального размера (600x600px)
|
||||
- ✅ Cropper с квадратной областью (1:1)
|
||||
- ✅ Индикатор размера в реальном времени
|
||||
- ✅ Превью обрезанного изображения
|
||||
- ✅ Обновление file input
|
||||
- ✅ Инструкции для FaceID
|
||||
- ✅ Модальное окно Bootstrap
|
||||
- ✅ Обработка ошибок
|
||||
- ✅ Cleanup при закрытии
|
||||
|
||||
## 🎉 Готово к тестированию!
|
||||
|
||||
Все изменения в ветке `feature/avatar-validation`.
|
||||
|
||||
**Следующие шаги:**
|
||||
1. Тестирование на локальном сервере
|
||||
2. Проверка валидации
|
||||
3. Проверка сохранения аватара
|
||||
4. Коммит и push изменений
|
||||
5. Создание PR в master (после тестирования)
|
||||
436
backend/static/js/components/avatarCropperHandler.js
Normal file
436
backend/static/js/components/avatarCropperHandler.js
Normal file
@ -0,0 +1,436 @@
|
||||
/**
|
||||
* avatarCropperHandler.js
|
||||
* Компонент для обрезки аватара с требованиями FaceID
|
||||
*
|
||||
* Требования для FaceID:
|
||||
* - Лицо должно занимать 70-80% кадра
|
||||
* - Центрирование лица в кадре
|
||||
* - Минимальный размер: 600x600px
|
||||
* - Соотношение сторон: 1:1 (квадрат)
|
||||
* - Формат: JPEG (оптимизированный для распознавания)
|
||||
*
|
||||
* Зависимости: Cropper.js (https://github.com/fengyuanchen/cropperjs)
|
||||
*/
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
// Селекторы
|
||||
fileInputSelector: 'input[type="file"][name="avatar"]',
|
||||
modalId: 'avatarCropperModal',
|
||||
previewImageId: 'avatarPreview',
|
||||
placeholderId: 'avatarPlaceholder',
|
||||
|
||||
// Настройки Cropper.js
|
||||
cropperOptions: {
|
||||
aspectRatio: 1, // Квадратное фото
|
||||
viewMode: 2, // Ограничить canvas областью контейнера
|
||||
dragMode: 'move', // Перемещение изображения
|
||||
autoCropArea: 0.8, // Начальный размер области обрезки (80%)
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
minCropBoxWidth: 200,
|
||||
minCropBoxHeight: 200,
|
||||
},
|
||||
|
||||
// Требования для FaceID
|
||||
minOutputSize: 600, // Минимальный размер выходного изображения
|
||||
outputSize: 800, // Рекомендуемый размер выходного изображения
|
||||
outputQuality: 0.92, // Качество JPEG (0.0 - 1.0)
|
||||
outputFormat: 'image/jpeg', // Формат выходного файла
|
||||
|
||||
// Тексты для UI
|
||||
texts: {
|
||||
modalTitle: 'Обрезка фото для FaceID',
|
||||
instructions: `
|
||||
<div class="alert alert-info mb-3">
|
||||
<h6 class="mb-2"><i class="bi-info-circle me-2"></i>Требования для системы распознавания лиц:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Лицо должно занимать 70-80% кадра</li>
|
||||
<li>Расположите лицо в центре рамки</li>
|
||||
<li>Избегайте теней и бликов</li>
|
||||
<li>Смотрите прямо в камеру</li>
|
||||
<li>Нейтральное выражение лица</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
cropButton: 'Применить обрезку',
|
||||
cancelButton: 'Отмена',
|
||||
errorTooSmall: 'Изображение слишком маленькое. Минимальный размер: ',
|
||||
errorInvalidFormat: 'Неподдерживаемый формат. Используйте JPG, PNG или GIF.',
|
||||
errorFileSize: 'Файл слишком большой. Максимум 10 МБ.',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Создает и управляет модальным окном для обрезки аватара
|
||||
*/
|
||||
export class AvatarCropper {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.cropper = null;
|
||||
this.currentFile = null;
|
||||
this.croppedBlob = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация компонента
|
||||
*/
|
||||
init() {
|
||||
this.createModal();
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает модальное окно для cropper
|
||||
*/
|
||||
createModal() {
|
||||
// Проверяем, не существует ли уже модальное окно
|
||||
if (document.getElementById(this.config.modalId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="${this.config.modalId}" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${this.config.texts.modalTitle}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${this.config.texts.instructions}
|
||||
|
||||
<!-- Контейнер для Cropper.js -->
|
||||
<div class="cropper-container-wrapper" style="max-height: 500px; overflow: hidden;">
|
||||
<img id="cropperImage" style="max-width: 100%; display: block;">
|
||||
</div>
|
||||
|
||||
<!-- Индикатор размера -->
|
||||
<div class="mt-3 text-center">
|
||||
<small class="text-muted" id="cropperSizeIndicator">
|
||||
Размер обрезанного фото: <span class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
${this.config.texts.cancelButton}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="cropperApplyBtn">
|
||||
<i class="bi-check-circle me-2"></i>${this.config.texts.cropButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Навешивает обработчики событий
|
||||
*/
|
||||
attachEventListeners() {
|
||||
// Обработчик выбора файла
|
||||
const fileInput = document.querySelector(this.config.fileInputSelector);
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||
}
|
||||
|
||||
// Обработчик кнопки "Применить"
|
||||
const applyBtn = document.getElementById('cropperApplyBtn');
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', () => this.applyCrop());
|
||||
}
|
||||
|
||||
// Обработчик закрытия модального окна
|
||||
const modal = document.getElementById(this.config.modalId);
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => this.destroyCropper());
|
||||
}
|
||||
|
||||
// Обработчик изменения области обрезки (для обновления индикатора размера)
|
||||
const cropperImage = document.getElementById('cropperImage');
|
||||
if (cropperImage) {
|
||||
cropperImage.addEventListener('crop', (e) => this.updateSizeIndicator(e.detail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка выбора файла
|
||||
*/
|
||||
async handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Валидация формата
|
||||
if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) {
|
||||
alert(this.config.texts.errorInvalidFormat);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация размера (10 МБ)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(this.config.texts.errorFileSize);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentFile = file;
|
||||
|
||||
// Проверяем размер изображения
|
||||
const isValidSize = await this.validateImageSize(file);
|
||||
if (!isValidSize) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Открываем cropper
|
||||
this.openCropper(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет размер изображения
|
||||
*/
|
||||
validateImageSize(file) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const minSize = this.config.minOutputSize;
|
||||
if (img.width < minSize || img.height < minSize) {
|
||||
alert(
|
||||
`${this.config.texts.errorTooSmall}${minSize}x${minSize}px\n` +
|
||||
`Ваше изображение: ${img.width}x${img.height}px`
|
||||
);
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
alert('Не удалось загрузить изображение');
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно с cropper
|
||||
*/
|
||||
openCropper(file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const cropperImage = document.getElementById('cropperImage');
|
||||
cropperImage.src = e.target.result;
|
||||
|
||||
// Показываем модальное окно
|
||||
const modal = new bootstrap.Modal(document.getElementById(this.config.modalId));
|
||||
modal.show();
|
||||
|
||||
// Инициализируем Cropper.js после отображения модального окна
|
||||
setTimeout(() => {
|
||||
this.initCropper(cropperImage);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует Cropper.js
|
||||
*/
|
||||
initCropper(imageElement) {
|
||||
// Уничтожаем предыдущий экземпляр, если есть
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
// Создаем новый экземпляр Cropper
|
||||
this.cropper = new Cropper(imageElement, {
|
||||
...this.config.cropperOptions,
|
||||
ready: () => {
|
||||
console.log('[AvatarCropper] Cropper ready');
|
||||
this.updateSizeIndicator();
|
||||
},
|
||||
crop: (event) => {
|
||||
this.updateSizeIndicator(event.detail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет индикатор размера обрезанного изображения
|
||||
*/
|
||||
updateSizeIndicator(detail) {
|
||||
if (!this.cropper) return;
|
||||
|
||||
const data = detail || this.cropper.getData();
|
||||
const width = Math.round(data.width);
|
||||
const height = Math.round(data.height);
|
||||
|
||||
const indicator = document.getElementById('cropperSizeIndicator');
|
||||
if (indicator) {
|
||||
const sizeSpan = indicator.querySelector('.fw-bold');
|
||||
if (sizeSpan) {
|
||||
const minSize = this.config.minOutputSize;
|
||||
const isValid = width >= minSize && height >= minSize;
|
||||
|
||||
sizeSpan.textContent = `${width}x${height}px`;
|
||||
sizeSpan.className = isValid ? 'fw-bold text-success' : 'fw-bold text-danger';
|
||||
|
||||
if (!isValid) {
|
||||
sizeSpan.textContent += ` (мин. ${minSize}x${minSize}px)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет обрезку и закрывает модальное окно
|
||||
*/
|
||||
async applyCrop() {
|
||||
if (!this.cropper) return;
|
||||
|
||||
const applyBtn = document.getElementById('cropperApplyBtn');
|
||||
const originalText = applyBtn.innerHTML;
|
||||
|
||||
try {
|
||||
// Показываем индикатор загрузки
|
||||
applyBtn.disabled = true;
|
||||
applyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
|
||||
|
||||
// Получаем обрезанное изображение
|
||||
const canvas = this.cropper.getCroppedCanvas({
|
||||
width: this.config.outputSize,
|
||||
height: this.config.outputSize,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
// Конвертируем в Blob
|
||||
this.croppedBlob = await new Promise((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => resolve(blob),
|
||||
this.config.outputFormat,
|
||||
this.config.outputQuality
|
||||
);
|
||||
});
|
||||
|
||||
// Создаем File объект из Blob
|
||||
const croppedFile = new File(
|
||||
[this.croppedBlob],
|
||||
this.currentFile.name,
|
||||
{ type: this.config.outputFormat }
|
||||
);
|
||||
|
||||
// Обновляем превью
|
||||
this.updatePreview(canvas);
|
||||
|
||||
// Обновляем input файла
|
||||
this.updateFileInput(croppedFile);
|
||||
|
||||
// Закрываем модальное окно
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById(this.config.modalId));
|
||||
modal.hide();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AvatarCropper] Error applying crop:', error);
|
||||
alert('Произошла ошибка при обрезке изображения');
|
||||
} finally {
|
||||
applyBtn.disabled = false;
|
||||
applyBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет превью аватара
|
||||
*/
|
||||
updatePreview(canvas) {
|
||||
const previewImg = document.getElementById(this.config.previewImageId);
|
||||
const placeholder = document.getElementById(this.config.placeholderId);
|
||||
|
||||
const dataUrl = canvas.toDataURL(this.config.outputFormat, this.config.outputQuality);
|
||||
|
||||
if (previewImg) {
|
||||
// Если превью - это img элемент
|
||||
previewImg.src = dataUrl;
|
||||
previewImg.classList.remove('d-none');
|
||||
|
||||
if (placeholder) {
|
||||
placeholder.classList.add('d-none');
|
||||
}
|
||||
} else if (placeholder && placeholder.tagName !== 'IMG') {
|
||||
// Если превью - это placeholder, заменяем его на img
|
||||
const img = document.createElement('img');
|
||||
img.id = this.config.previewImageId;
|
||||
img.src = dataUrl;
|
||||
img.alt = 'Фото';
|
||||
img.className = 'rounded';
|
||||
img.style.cssText = 'width: 120px; height: 120px; object-fit: cover; border: 2px solid #dee2e6;';
|
||||
placeholder.replaceWith(img);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет input файла с обрезанным изображением
|
||||
*/
|
||||
updateFileInput(croppedFile) {
|
||||
const fileInput = document.querySelector(this.config.fileInputSelector);
|
||||
if (!fileInput) return;
|
||||
|
||||
// Создаем новый DataTransfer объект
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(croppedFile);
|
||||
|
||||
// Обновляем files input
|
||||
fileInput.files = dataTransfer.files;
|
||||
|
||||
console.log('[AvatarCropper] File input updated with cropped image:', {
|
||||
name: croppedFile.name,
|
||||
size: croppedFile.size,
|
||||
type: croppedFile.type
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Уничтожает экземпляр Cropper
|
||||
*/
|
||||
destroyCropper() {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
this.cropper = null;
|
||||
}
|
||||
|
||||
// Очищаем src изображения
|
||||
const cropperImage = document.getElementById('cropperImage');
|
||||
if (cropperImage) {
|
||||
cropperImage.src = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует Avatar Cropper
|
||||
* @param {Object} config - Конфигурация
|
||||
* @returns {AvatarCropper} Экземпляр AvatarCropper
|
||||
*/
|
||||
export function initAvatarCropper(config = {}) {
|
||||
return new AvatarCropper(config);
|
||||
}
|
||||
@ -115,7 +115,13 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
{% bootstrap_field form.avatar %}
|
||||
<small class="text-muted">Рекомендуемый размер: 200×200 px</small>
|
||||
<small class="text-muted d-block">
|
||||
<i class="bi-camera-fill me-1"></i>
|
||||
<strong>Требования для системы распознавания лиц (FaceID):</strong><br>
|
||||
• Лицо должно занимать 70-80% кадра<br>
|
||||
• Минимальный размер: 600x600px<br>
|
||||
• Форматы: JPG, PNG, GIF. Максимум 10 МБ
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -133,20 +139,25 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Cropper.js для обрезки аватара -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" integrity="sha512-hvNR0F/e2J7zPPfLC9auFe3/SE0yG4aJCOd/qxew74NN7eyiSKjr7xJJMu1Jy2wf7FXITpWS1E/RY8yzuXN7VA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js" integrity="sha512-9KkIqdfN7ipEW6B6k+Aq20PV31bjODg4AA52W+tYtAE0jE0kMx49bjJ3FgvS56wzmyfMUHbQ4Km2b7l9+Y/+Eg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initPasswordToggle } from "{% static 'js/components/passwordToggleHandler.js' %}";
|
||||
import { initAvatarPreview } from "{% static 'js/components/avatarPreviewHandler.js' %}";
|
||||
import { initAvatarCropper } from "{% static 'js/components/avatarCropperHandler.js' %}";
|
||||
import { initLiveValidation } from "{% static 'js/components/liveValidationHandler.js' %}";
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Переключение видимости пароля
|
||||
initPasswordToggle();
|
||||
|
||||
// Превью аватара
|
||||
initAvatarPreview({
|
||||
// Avatar Cropper для FaceID (заменяет простое превью)
|
||||
initAvatarCropper({
|
||||
fileInputSelector: 'input[type="file"][name="{{ form.avatar.html_name }}"]',
|
||||
previewImageId: 'avatarPreview',
|
||||
placeholderId: 'avatarPlaceholder'
|
||||
placeholderId: 'avatarPlaceholder',
|
||||
modalId: 'avatarCropperModal'
|
||||
});
|
||||
|
||||
// Live validation - валидация в реальном времени
|
||||
|
||||
@ -36,7 +36,13 @@
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<input type="file" class="form-control mb-2" id="avatarInput" name="avatar" accept="image/*">
|
||||
<small class="text-muted d-block">Форматы: JPG, PNG, GIF. Максимум 5 МБ.</small>
|
||||
<small class="text-muted d-block">
|
||||
<i class="bi-camera-fill me-1"></i>
|
||||
<strong>Требования для системы распознавания лиц (FaceID):</strong><br>
|
||||
• Лицо должно занимать 70-80% кадра<br>
|
||||
• Минимальный размер: 600x600px<br>
|
||||
• Форматы: JPG, PNG, GIF. Максимум 10 МБ
|
||||
</small>
|
||||
{% if emp.avatar %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger mt-2" id="removeAvatarBtn">
|
||||
<i class="bi-trash"></i> Удалить фото
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
{% load static %}
|
||||
{# Cropper.js для обрезки аватара #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" integrity="sha512-hvNR0F/e2J7zPPfLC9auFe3/SE0yG4aJCOd/qxew74NN7eyiSKjr7xJJMu1Jy2wf7FXITpWS1E/RY8yzuXN7VA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js" integrity="sha512-9KkIqdfN7ipEW6B6k+Aq20PV31bjODg4AA52W+tYtAE0jE0kMx49bjJ3FgvS56wzmyfMUHbQ4Km2b7l9+Y/+Eg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
|
||||
{# Подключаем модули для управления формой сотрудника #}
|
||||
<script type="module">
|
||||
import { initEmployeeForm } from '{% static "js/components/employeeFormHandler.js" %}';
|
||||
import { initPositionManager } from '{% static "js/components/positionManager.js" %}';
|
||||
import { initPositionGroupPicker, initPositionEditGroupPicker } from '{% static "js/components/groupPickers.js" %}';
|
||||
import { initEmployeeGroupsManager } from '{% static "js/components/employeeGroupsManager.js" %}';
|
||||
import { initAvatarCropper } from '{% static "js/components/avatarCropperHandler.js" %}';
|
||||
|
||||
// Инициализация после полной загрузки DOM
|
||||
if (document.readyState === 'loading') {
|
||||
@ -28,59 +33,27 @@
|
||||
if (editGroupPicker) window.editGroupPicker = editGroupPicker;
|
||||
if (groupsMgr) window.employeeGroupsManager = groupsMgr;
|
||||
|
||||
// Инициализация загрузчика аватара
|
||||
initAvatarUploader();
|
||||
// Инициализация Avatar Cropper для FaceID
|
||||
initAvatarCropper({
|
||||
fileInputSelector: '#avatarInput',
|
||||
previewImageId: 'avatarPreview',
|
||||
modalId: 'avatarCropperModal'
|
||||
});
|
||||
|
||||
// Инициализация удаления аватара
|
||||
initAvatarRemover();
|
||||
}
|
||||
|
||||
// Обработка загрузки и предпросмотра аватара
|
||||
function initAvatarUploader() {
|
||||
// Обработка удаления аватара
|
||||
function initAvatarRemover() {
|
||||
const avatarInput = document.getElementById('avatarInput');
|
||||
const avatarPreview = document.getElementById('avatarPreview');
|
||||
const removeBtn = document.getElementById('removeAvatarBtn');
|
||||
|
||||
if (!avatarInput || !avatarPreview) return;
|
||||
|
||||
// Предпросмотр при выборе файла
|
||||
avatarInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Проверка размера (5 МБ)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Файл слишком большой. Максимум 5 МБ.');
|
||||
avatarInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) {
|
||||
alert('Неподдерживаемый формат. Используйте JPG, PNG или GIF.');
|
||||
avatarInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем предпросмотр
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (avatarPreview.tagName === 'IMG') {
|
||||
avatarPreview.src = event.target.result;
|
||||
} else {
|
||||
// Заменяем placeholder на img
|
||||
const img = document.createElement('img');
|
||||
img.id = 'avatarPreview';
|
||||
img.src = event.target.result;
|
||||
img.alt = 'Фото';
|
||||
img.className = 'rounded';
|
||||
img.style.cssText = 'width: 120px; height: 120px; object-fit: cover; border: 2px solid #dee2e6;';
|
||||
avatarPreview.replaceWith(img);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
if (!removeBtn) return;
|
||||
|
||||
// Удаление фото
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Удалить фотографию?')) return;
|
||||
|
||||
try {
|
||||
@ -121,7 +94,6 @@
|
||||
console.error('Avatar remove error:', err);
|
||||
alert('Ошибка при удалении фото');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user