Compare commits
2 Commits
ae54fb7ed1
...
341911a8d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 341911a8d3 | |||
| d78c296e5a |
@@ -60,6 +60,7 @@ INSTALLED_APPS = [
|
||||
'links',
|
||||
'customization',
|
||||
'api',
|
||||
'export_import',
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'django_extensions',
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('api.urls')), # API endpoints
|
||||
path('api/users/', include('users.urls')), # User management API
|
||||
path('api/', include('export_import.urls')), # Export/Import API
|
||||
path('users/', include('users.urls')), # User management app
|
||||
path('links/', include('links.urls')), # Link management app
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
0
backend/export_import/__init__.py
Normal file
0
backend/export_import/__init__.py
Normal file
3
backend/export_import/admin.py
Normal file
3
backend/export_import/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
backend/export_import/apps.py
Normal file
7
backend/export_import/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportImportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'export_import'
|
||||
verbose_name = 'Export/Import'
|
||||
63
backend/export_import/migrations/0001_initial.py
Normal file
63
backend/export_import/migrations/0001_initial.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-09 05:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExportTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
|
||||
('include_groups', models.BooleanField(default=True, verbose_name='Включить группы')),
|
||||
('include_links', models.BooleanField(default=True, verbose_name='Включить ссылки')),
|
||||
('include_styles', models.BooleanField(default=True, verbose_name='Включить стили')),
|
||||
('include_media', models.BooleanField(default=True, verbose_name='Включить медиафайлы')),
|
||||
('export_file', models.FileField(blank=True, null=True, upload_to='exports/', verbose_name='Файл экспорта')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача экспорта',
|
||||
'verbose_name_plural': 'Задачи экспорта',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ImportTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
|
||||
('import_file', models.FileField(upload_to='imports/', verbose_name='Файл для импорта')),
|
||||
('import_groups', models.BooleanField(default=True, verbose_name='Импортировать группы')),
|
||||
('import_links', models.BooleanField(default=True, verbose_name='Импортировать ссылки')),
|
||||
('import_styles', models.BooleanField(default=True, verbose_name='Импортировать стили')),
|
||||
('import_media', models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')),
|
||||
('overwrite_existing', models.BooleanField(default=False, verbose_name='Перезаписать существующие')),
|
||||
('imported_groups_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')),
|
||||
('imported_links_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')),
|
||||
('imported_media_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача импорта',
|
||||
'verbose_name_plural': 'Задачи импорта',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/export_import/migrations/__init__.py
Normal file
0
backend/export_import/migrations/__init__.py
Normal file
92
backend/export_import/models.py
Normal file
92
backend/export_import/models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ExportTask(models.Model):
|
||||
"""Модель для отслеживания задач экспорта профиля"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает выполнения'),
|
||||
('processing', 'В процессе'),
|
||||
('completed', 'Завершен'),
|
||||
('failed', 'Ошибка'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
|
||||
|
||||
# Опции экспорта
|
||||
include_groups = models.BooleanField(default=True, verbose_name='Включить группы')
|
||||
include_links = models.BooleanField(default=True, verbose_name='Включить ссылки')
|
||||
include_styles = models.BooleanField(default=True, verbose_name='Включить стили')
|
||||
include_media = models.BooleanField(default=True, verbose_name='Включить медиафайлы')
|
||||
|
||||
# Файл с результатом
|
||||
export_file = models.FileField(
|
||||
upload_to='exports/',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Файл экспорта'
|
||||
)
|
||||
|
||||
# Логирование
|
||||
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Задача экспорта'
|
||||
verbose_name_plural = 'Задачи экспорта'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'Экспорт {self.user.username} - {self.get_status_display()}'
|
||||
|
||||
|
||||
class ImportTask(models.Model):
|
||||
"""Модель для отслеживания задач импорта профиля"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает выполнения'),
|
||||
('processing', 'В процессе'),
|
||||
('completed', 'Завершен'),
|
||||
('failed', 'Ошибка'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
|
||||
|
||||
# Файл для импорта
|
||||
import_file = models.FileField(upload_to='imports/', verbose_name='Файл для импорта')
|
||||
|
||||
# Опции импорта
|
||||
import_groups = models.BooleanField(default=True, verbose_name='Импортировать группы')
|
||||
import_links = models.BooleanField(default=True, verbose_name='Импортировать ссылки')
|
||||
import_styles = models.BooleanField(default=True, verbose_name='Импортировать стили')
|
||||
import_media = models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')
|
||||
|
||||
# Стратегия конфликтов
|
||||
overwrite_existing = models.BooleanField(default=False, verbose_name='Перезаписать существующие')
|
||||
|
||||
# Результаты импорта
|
||||
imported_groups_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')
|
||||
imported_links_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')
|
||||
imported_media_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')
|
||||
|
||||
# Логирование
|
||||
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Задача импорта'
|
||||
verbose_name_plural = 'Задачи импорта'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'Импорт {self.user.username} - {self.get_status_display()}'
|
||||
3
backend/export_import/tests.py
Normal file
3
backend/export_import/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
backend/export_import/urls.py
Normal file
16
backend/export_import/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Экспорт профиля
|
||||
path('export/', views.create_export, name='create_export'),
|
||||
path('export/<int:task_id>/', views.export_status, name='export_status'),
|
||||
path('export/<int:task_id>/download/', views.download_export, name='download_export'),
|
||||
path('export/list/', views.export_list, name='export_list'),
|
||||
|
||||
# Импорт профиля
|
||||
path('import/', views.create_import, name='create_import'),
|
||||
path('import/<int:task_id>/', views.import_status, name='import_status'),
|
||||
path('import/list/', views.import_list, name='import_list'),
|
||||
path('import/preview/', views.preview_import, name='preview_import'),
|
||||
]
|
||||
732
backend/export_import/views.py
Normal file
732
backend/export_import/views.py
Normal file
@@ -0,0 +1,732 @@
|
||||
import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.utils import timezone
|
||||
import shutil
|
||||
|
||||
from .models import ExportTask, ImportTask
|
||||
from users.models import User
|
||||
from links.models import LinkGroup, Link
|
||||
from customization.models import DesignSettings
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_export(request):
|
||||
"""Создание задачи экспорта профиля"""
|
||||
|
||||
# Получаем параметры экспорта
|
||||
include_groups = request.data.get('include_groups', True)
|
||||
include_links = request.data.get('include_links', True)
|
||||
include_styles = request.data.get('include_styles', True)
|
||||
include_media = request.data.get('include_media', True)
|
||||
|
||||
# Создаем задачу экспорта
|
||||
export_task = ExportTask.objects.create(
|
||||
user=request.user,
|
||||
include_groups=include_groups,
|
||||
include_links=include_links,
|
||||
include_styles=include_styles,
|
||||
include_media=include_media,
|
||||
)
|
||||
|
||||
try:
|
||||
# Обновляем статус
|
||||
export_task.status = 'processing'
|
||||
export_task.save()
|
||||
|
||||
# Создаем архив с данными профиля
|
||||
export_file_path = _create_profile_archive(export_task)
|
||||
|
||||
# Сохраняем путь к файлу в задаче
|
||||
with open(export_file_path, 'rb') as f:
|
||||
export_task.export_file.save(
|
||||
f'profile_export_{export_task.user.username}_{timezone.now().strftime("%Y%m%d_%H%M%S")}.zip',
|
||||
ContentFile(f.read()),
|
||||
save=True
|
||||
)
|
||||
|
||||
# Удаляем временный файл
|
||||
os.remove(export_file_path)
|
||||
|
||||
export_task.status = 'completed'
|
||||
export_task.save()
|
||||
|
||||
return Response({
|
||||
'task_id': export_task.id,
|
||||
'status': export_task.status,
|
||||
'download_url': f'/api/export/{export_task.id}/download/',
|
||||
'message': 'Экспорт профиля завершен успешно'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
export_task.status = 'failed'
|
||||
export_task.error_message = str(e)
|
||||
export_task.save()
|
||||
|
||||
return Response({
|
||||
'error': 'Ошибка при создании экспорта',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def download_export(request, task_id):
|
||||
"""Скачивание файла экспорта"""
|
||||
|
||||
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
|
||||
|
||||
if export_task.status != 'completed' or not export_task.export_file:
|
||||
return Response({
|
||||
'error': 'Файл экспорта недоступен'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
response = HttpResponse(
|
||||
export_task.export_file.read(),
|
||||
content_type='application/zip'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="profile_export_{request.user.username}.zip"'
|
||||
return response
|
||||
|
||||
except FileNotFoundError:
|
||||
raise Http404("Файл экспорта не найден")
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def export_status(request, task_id):
|
||||
"""Получение статуса задачи экспорта"""
|
||||
|
||||
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
|
||||
|
||||
return Response({
|
||||
'task_id': export_task.id,
|
||||
'status': export_task.status,
|
||||
'created_at': export_task.created_at,
|
||||
'updated_at': export_task.updated_at,
|
||||
'error_message': export_task.error_message,
|
||||
'download_url': f'/api/export/{export_task.id}/download/' if export_task.status == 'completed' else None
|
||||
})
|
||||
|
||||
|
||||
def _create_profile_archive(export_task):
|
||||
"""Создание архива с данными профиля"""
|
||||
|
||||
user = export_task.user
|
||||
|
||||
# Создаем временную директорию
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
profile_dir = Path(temp_dir) / 'profile_export'
|
||||
profile_dir.mkdir()
|
||||
|
||||
# Создаем структуру данных для экспорта
|
||||
export_data = {
|
||||
'export_info': {
|
||||
'username': user.username,
|
||||
'export_date': timezone.now().isoformat(),
|
||||
'export_options': {
|
||||
'include_groups': export_task.include_groups,
|
||||
'include_links': export_task.include_links,
|
||||
'include_styles': export_task.include_styles,
|
||||
'include_media': export_task.include_media,
|
||||
}
|
||||
},
|
||||
'user_data': {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'bio': getattr(user, 'bio', ''),
|
||||
'avatar': user.avatar.url if user.avatar else None,
|
||||
'cover': user.cover.url if user.cover else None,
|
||||
},
|
||||
'groups': [],
|
||||
'links': [],
|
||||
'design_settings': {},
|
||||
}
|
||||
|
||||
# Экспорт групп
|
||||
if export_task.include_groups:
|
||||
for group in LinkGroup.objects.filter(owner=user):
|
||||
group_data = {
|
||||
'id': group.id,
|
||||
'title': group.title,
|
||||
'description': group.description,
|
||||
'image': group.image.url if group.image else None,
|
||||
'is_active': group.is_active,
|
||||
'is_public': group.is_public,
|
||||
'is_featured': group.is_featured,
|
||||
'created_at': group.created_at.isoformat(),
|
||||
'order': group.order,
|
||||
}
|
||||
export_data['groups'].append(group_data)
|
||||
|
||||
# Экспорт ссылок
|
||||
if export_task.include_links:
|
||||
for link in Link.objects.filter(group__owner=user):
|
||||
link_data = {
|
||||
'id': link.id,
|
||||
'group_id': link.group.id,
|
||||
'title': link.title,
|
||||
'url': link.url,
|
||||
'description': link.description,
|
||||
'image': link.image.url if link.image else None,
|
||||
'is_active': link.is_active,
|
||||
'is_public': link.is_public,
|
||||
'is_featured': link.is_featured,
|
||||
'created_at': link.created_at.isoformat(),
|
||||
'order': link.order,
|
||||
}
|
||||
export_data['links'].append(link_data)
|
||||
|
||||
# Экспорт настроек дизайна
|
||||
if export_task.include_styles:
|
||||
try:
|
||||
design_settings = DesignSettings.objects.get(user=user)
|
||||
export_data['design_settings'] = {
|
||||
'background_image': design_settings.background_image.url if design_settings.background_image else None,
|
||||
'theme_color': design_settings.theme_color,
|
||||
'dashboard_layout': design_settings.dashboard_layout,
|
||||
'groups_default_expanded': design_settings.groups_default_expanded,
|
||||
'show_group_icons': design_settings.show_group_icons,
|
||||
'show_link_icons': design_settings.show_link_icons,
|
||||
'dashboard_background_color': design_settings.dashboard_background_color,
|
||||
'font_family': design_settings.font_family,
|
||||
'custom_css': design_settings.custom_css,
|
||||
'header_text_color': design_settings.header_text_color,
|
||||
'group_text_color': design_settings.group_text_color,
|
||||
'link_text_color': design_settings.link_text_color,
|
||||
'cover_overlay_enabled': design_settings.cover_overlay_enabled,
|
||||
'cover_overlay_color': design_settings.cover_overlay_color,
|
||||
'cover_overlay_opacity': design_settings.cover_overlay_opacity,
|
||||
'group_overlay_enabled': design_settings.group_overlay_enabled,
|
||||
'group_overlay_color': design_settings.group_overlay_color,
|
||||
'group_overlay_opacity': design_settings.group_overlay_opacity,
|
||||
'show_groups_title': design_settings.show_groups_title,
|
||||
'group_description_text_color': design_settings.group_description_text_color,
|
||||
'body_font_family': design_settings.body_font_family,
|
||||
'heading_font_family': design_settings.heading_font_family,
|
||||
'template_id': design_settings.template_id,
|
||||
'link_overlay_enabled': design_settings.link_overlay_enabled,
|
||||
'link_overlay_color': design_settings.link_overlay_color,
|
||||
'link_overlay_opacity': design_settings.link_overlay_opacity,
|
||||
}
|
||||
except DesignSettings.DoesNotExist:
|
||||
export_data['design_settings'] = {}
|
||||
|
||||
# Сохраняем данные в JSON
|
||||
json_file = profile_dir / 'profile_data.json'
|
||||
with open(json_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Копируем медиафайлы
|
||||
if export_task.include_media:
|
||||
media_dir = profile_dir / 'media'
|
||||
media_dir.mkdir()
|
||||
|
||||
# Копируем файлы пользователя
|
||||
_copy_user_media_files(user, media_dir, export_data)
|
||||
|
||||
# Создаем ZIP архив
|
||||
archive_path = temp_dir + '.zip'
|
||||
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(profile_dir):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
arc_name = file_path.relative_to(profile_dir)
|
||||
zipf.write(file_path, arc_name)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def _copy_user_media_files(user, media_dir, export_data):
|
||||
"""Копирование медиафайлов пользователя"""
|
||||
|
||||
media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
# Функция для копирования файла
|
||||
def copy_file_if_exists(url, subdir):
|
||||
if url and url.startswith('/storage/'):
|
||||
file_path = media_root / url[9:] # убираем /storage/
|
||||
if file_path.exists():
|
||||
target_dir = media_dir / subdir
|
||||
target_dir.mkdir(exist_ok=True, parents=True)
|
||||
shutil.copy2(file_path, target_dir / file_path.name)
|
||||
|
||||
# Аватар и обложка пользователя
|
||||
copy_file_if_exists(export_data['user_data']['avatar'], 'avatars')
|
||||
copy_file_if_exists(export_data['user_data']['cover'], 'avatars')
|
||||
|
||||
# Фоновые изображения в настройках дизайна
|
||||
if export_data['design_settings'].get('background_image'):
|
||||
copy_file_if_exists(export_data['design_settings']['background_image'], 'customization')
|
||||
|
||||
# Изображения групп
|
||||
for group in export_data['groups']:
|
||||
copy_file_if_exists(group.get('image'), 'link_groups')
|
||||
|
||||
# Изображения ссылок
|
||||
for link in export_data['links']:
|
||||
copy_file_if_exists(link.get('image'), 'links')
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def export_list(request):
|
||||
"""Получение списка задач экспорта пользователя"""
|
||||
|
||||
# Для тестирования - простой ответ
|
||||
if not request.user.is_authenticated:
|
||||
return Response({
|
||||
'message': 'Export API доступен',
|
||||
'authenticated': False
|
||||
})
|
||||
|
||||
export_tasks = ExportTask.objects.filter(user=request.user)
|
||||
|
||||
tasks_data = []
|
||||
for task in export_tasks:
|
||||
tasks_data.append({
|
||||
'id': task.id,
|
||||
'status': task.status,
|
||||
'created_at': task.created_at,
|
||||
'updated_at': task.updated_at,
|
||||
'include_groups': task.include_groups,
|
||||
'include_links': task.include_links,
|
||||
'include_styles': task.include_styles,
|
||||
'include_media': task.include_media,
|
||||
'download_url': f'/api/export/{task.id}/download/' if task.status == 'completed' else None,
|
||||
'error_message': task.error_message,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'tasks': tasks_data,
|
||||
'count': len(tasks_data)
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_import(request):
|
||||
"""Создание задачи импорта профиля"""
|
||||
|
||||
# Проверяем наличие файла
|
||||
if 'import_file' not in request.FILES:
|
||||
return Response({
|
||||
'error': 'Файл для импорта не предоставлен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import_file = request.FILES['import_file']
|
||||
|
||||
# Проверяем тип файла
|
||||
if not import_file.name.endswith('.zip'):
|
||||
return Response({
|
||||
'error': 'Поддерживаются только ZIP архивы'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Получаем параметры импорта из POST данных (для multipart/form-data)
|
||||
def get_bool_param(name, default=True):
|
||||
value = request.data.get(name, request.POST.get(name, str(default)))
|
||||
return str(value).lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
import_groups = get_bool_param('import_groups', True)
|
||||
import_links = get_bool_param('import_links', True)
|
||||
import_styles = get_bool_param('import_styles', True)
|
||||
import_media = get_bool_param('import_media', True)
|
||||
overwrite_existing = get_bool_param('overwrite_existing', False)
|
||||
|
||||
# Создаем задачу импорта
|
||||
try:
|
||||
import_task = ImportTask.objects.create(
|
||||
user=request.user,
|
||||
import_file=import_file,
|
||||
import_groups=import_groups,
|
||||
import_links=import_links,
|
||||
import_styles=import_styles,
|
||||
import_media=import_media,
|
||||
overwrite_existing=overwrite_existing,
|
||||
)
|
||||
|
||||
# Обновляем статус
|
||||
import_task.status = 'processing'
|
||||
import_task.save()
|
||||
|
||||
# Выполняем импорт
|
||||
_process_import(import_task)
|
||||
|
||||
import_task.status = 'completed'
|
||||
import_task.save()
|
||||
|
||||
return Response({
|
||||
'task_id': import_task.id,
|
||||
'status': import_task.status,
|
||||
'imported_groups_count': import_task.imported_groups_count,
|
||||
'imported_links_count': import_task.imported_links_count,
|
||||
'imported_media_count': import_task.imported_media_count,
|
||||
'message': 'Импорт профиля завершен успешно'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Если задача была создана, обновляем её статус
|
||||
if 'import_task' in locals():
|
||||
import_task.status = 'failed'
|
||||
import_task.error_message = str(e)
|
||||
import_task.save()
|
||||
|
||||
return Response({
|
||||
'error': 'Ошибка при импорте',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def import_status(request, task_id):
|
||||
"""Получение статуса задачи импорта"""
|
||||
|
||||
import_task = get_object_or_404(ImportTask, id=task_id, user=request.user)
|
||||
|
||||
return Response({
|
||||
'task_id': import_task.id,
|
||||
'status': import_task.status,
|
||||
'created_at': import_task.created_at,
|
||||
'updated_at': import_task.updated_at,
|
||||
'import_groups': import_task.import_groups,
|
||||
'import_links': import_task.import_links,
|
||||
'import_styles': import_task.import_styles,
|
||||
'import_media': import_task.import_media,
|
||||
'overwrite_existing': import_task.overwrite_existing,
|
||||
'imported_groups_count': import_task.imported_groups_count,
|
||||
'imported_links_count': import_task.imported_links_count,
|
||||
'imported_media_count': import_task.imported_media_count,
|
||||
'error_message': import_task.error_message,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def import_list(request):
|
||||
"""Получение списка задач импорта пользователя"""
|
||||
|
||||
import_tasks = ImportTask.objects.filter(user=request.user)
|
||||
|
||||
tasks_data = []
|
||||
for task in import_tasks:
|
||||
tasks_data.append({
|
||||
'id': task.id,
|
||||
'status': task.status,
|
||||
'created_at': task.created_at,
|
||||
'updated_at': task.updated_at,
|
||||
'import_groups': task.import_groups,
|
||||
'import_links': task.import_links,
|
||||
'import_styles': task.import_styles,
|
||||
'import_media': task.import_media,
|
||||
'overwrite_existing': task.overwrite_existing,
|
||||
'imported_groups_count': task.imported_groups_count,
|
||||
'imported_links_count': task.imported_links_count,
|
||||
'imported_media_count': task.imported_media_count,
|
||||
'error_message': task.error_message,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'tasks': tasks_data,
|
||||
'count': len(tasks_data)
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def preview_import(request):
|
||||
"""Предварительный просмотр содержимого архива импорта"""
|
||||
|
||||
if 'import_file' not in request.FILES:
|
||||
return Response({
|
||||
'error': 'Файл для импорта не предоставлен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import_file = request.FILES['import_file']
|
||||
|
||||
if not import_file.name.endswith('.zip'):
|
||||
return Response({
|
||||
'error': 'Поддерживаются только ZIP архивы'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive_path = Path(temp_dir) / 'preview.zip'
|
||||
|
||||
# Сохраняем файл
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in import_file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
# Извлекаем архив
|
||||
extract_dir = Path(temp_dir) / 'extracted'
|
||||
with zipfile.ZipFile(archive_path, 'r') as zipf:
|
||||
zipf.extractall(extract_dir)
|
||||
|
||||
# Читаем данные профиля
|
||||
profile_data_path = extract_dir / 'profile_data.json'
|
||||
if not profile_data_path.exists():
|
||||
return Response({
|
||||
'error': 'Файл profile_data.json не найден в архиве'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with open(profile_data_path, 'r', encoding='utf-8') as f:
|
||||
profile_data = json.load(f)
|
||||
|
||||
# Формируем превью
|
||||
preview = {
|
||||
'export_info': profile_data.get('export_info', {}),
|
||||
'user_data': profile_data.get('user_data', {}),
|
||||
'groups_count': len(profile_data.get('groups', [])),
|
||||
'links_count': len(profile_data.get('links', [])),
|
||||
'has_design_settings': bool(profile_data.get('design_settings')),
|
||||
'media_files': _count_media_files(extract_dir),
|
||||
'groups_preview': profile_data.get('groups', [])[:5], # Первые 5 групп для превью
|
||||
'links_preview': profile_data.get('links', [])[:10], # Первые 10 ссылок для превью
|
||||
}
|
||||
|
||||
return Response(preview)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': 'Ошибка при обработке архива',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def _count_media_files(extract_dir):
|
||||
"""Подсчет медиафайлов в архиве"""
|
||||
|
||||
media_dir = extract_dir / 'media'
|
||||
if not media_dir.exists():
|
||||
return {}
|
||||
|
||||
counts = {}
|
||||
for category in ['avatars', 'customization', 'link_groups', 'links']:
|
||||
category_dir = media_dir / category
|
||||
if category_dir.exists():
|
||||
counts[category] = len([f for f in category_dir.iterdir() if f.is_file()])
|
||||
else:
|
||||
counts[category] = 0
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _process_import(import_task):
|
||||
"""Обработка импорта профиля"""
|
||||
|
||||
user = import_task.user
|
||||
|
||||
# Создаем временную директорию для извлечения архива
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive_path = Path(temp_dir) / 'import.zip'
|
||||
|
||||
# Сохраняем файл во временную директорию
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in import_task.import_file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
# Извлекаем архив
|
||||
extract_dir = Path(temp_dir) / 'extracted'
|
||||
with zipfile.ZipFile(archive_path, 'r') as zipf:
|
||||
zipf.extractall(extract_dir)
|
||||
|
||||
# Читаем данные профиля
|
||||
profile_data_path = extract_dir / 'profile_data.json'
|
||||
if not profile_data_path.exists():
|
||||
raise Exception('Файл profile_data.json не найден в архиве')
|
||||
|
||||
with open(profile_data_path, 'r', encoding='utf-8') as f:
|
||||
profile_data = json.load(f)
|
||||
|
||||
# Импортируем данные в транзакции
|
||||
with transaction.atomic():
|
||||
_import_profile_data(import_task, profile_data, extract_dir)
|
||||
|
||||
|
||||
def _import_profile_data(import_task, profile_data, extract_dir):
|
||||
"""Импорт данных профиля"""
|
||||
|
||||
user = import_task.user
|
||||
|
||||
# Импорт групп
|
||||
if import_task.import_groups and 'groups' in profile_data:
|
||||
groups_count = _import_groups(user, profile_data['groups'], import_task.overwrite_existing)
|
||||
import_task.imported_groups_count = groups_count
|
||||
|
||||
# Импорт ссылок
|
||||
if import_task.import_links and 'links' in profile_data:
|
||||
links_count = _import_links(user, profile_data['links'], profile_data.get('groups', []), import_task.overwrite_existing)
|
||||
import_task.imported_links_count = links_count
|
||||
|
||||
# Импорт настроек дизайна
|
||||
if import_task.import_styles and 'design_settings' in profile_data:
|
||||
_import_design_settings(user, profile_data['design_settings'], import_task.overwrite_existing)
|
||||
|
||||
# Импорт медиафайлов
|
||||
if import_task.import_media:
|
||||
media_count = _import_media_files(user, extract_dir, import_task.overwrite_existing)
|
||||
import_task.imported_media_count = media_count
|
||||
|
||||
import_task.save()
|
||||
|
||||
|
||||
def _import_groups(user, groups_data, overwrite_existing):
|
||||
"""Импорт групп ссылок"""
|
||||
|
||||
imported_count = 0
|
||||
|
||||
for group_data in groups_data:
|
||||
# Проверяем существование группы по названию
|
||||
existing_group = LinkGroup.objects.filter(
|
||||
owner=user,
|
||||
title=group_data['title']
|
||||
).first()
|
||||
|
||||
if existing_group and not overwrite_existing:
|
||||
continue # Пропускаем если группа существует и перезапись отключена
|
||||
|
||||
# Создаем или обновляем группу
|
||||
group_defaults = {
|
||||
'description': group_data.get('description', ''),
|
||||
'is_active': group_data.get('is_active', True),
|
||||
'is_public': group_data.get('is_public', False),
|
||||
'is_featured': group_data.get('is_featured', False),
|
||||
'order': group_data.get('order', 0),
|
||||
}
|
||||
|
||||
group, created = LinkGroup.objects.update_or_create(
|
||||
owner=user,
|
||||
title=group_data['title'],
|
||||
defaults=group_defaults
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
|
||||
|
||||
def _import_links(user, links_data, groups_data, overwrite_existing):
|
||||
"""Импорт ссылок"""
|
||||
|
||||
imported_count = 0
|
||||
|
||||
# Создаем словарь соответствия старых ID групп к новым объектам
|
||||
group_mapping = {}
|
||||
for group_data in groups_data:
|
||||
group = LinkGroup.objects.filter(
|
||||
owner=user,
|
||||
title=group_data['title']
|
||||
).first()
|
||||
if group:
|
||||
group_mapping[group_data['id']] = group
|
||||
|
||||
for link_data in links_data:
|
||||
# Находим группу для ссылки
|
||||
old_group_id = link_data.get('group_id')
|
||||
if old_group_id not in group_mapping:
|
||||
continue # Пропускаем если группа не найдена
|
||||
|
||||
target_group = group_mapping[old_group_id]
|
||||
|
||||
# Проверяем существование ссылки по URL и группе
|
||||
existing_link = Link.objects.filter(
|
||||
group=target_group,
|
||||
url=link_data['url']
|
||||
).first()
|
||||
|
||||
if existing_link and not overwrite_existing:
|
||||
continue # Пропускаем если ссылка существует и перезапись отключена
|
||||
|
||||
# Создаем или обновляем ссылку
|
||||
link_defaults = {
|
||||
'title': link_data.get('title', ''),
|
||||
'description': link_data.get('description', ''),
|
||||
'is_active': link_data.get('is_active', True),
|
||||
'is_public': link_data.get('is_public', False),
|
||||
'is_featured': link_data.get('is_featured', False),
|
||||
'order': link_data.get('order', 0),
|
||||
}
|
||||
|
||||
link, created = Link.objects.update_or_create(
|
||||
group=target_group,
|
||||
url=link_data['url'],
|
||||
defaults=link_defaults
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
|
||||
|
||||
def _import_design_settings(user, design_data, overwrite_existing):
|
||||
"""Импорт настроек дизайна"""
|
||||
|
||||
if not design_data:
|
||||
return
|
||||
|
||||
# Получаем или создаем настройки дизайна
|
||||
design_settings, created = DesignSettings.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={}
|
||||
)
|
||||
|
||||
if not created and not overwrite_existing:
|
||||
return # Пропускаем если настройки существуют и перезапись отключена
|
||||
|
||||
# Обновляем настройки
|
||||
for field, value in design_data.items():
|
||||
if field != 'background_image' and hasattr(design_settings, field):
|
||||
setattr(design_settings, field, value)
|
||||
|
||||
design_settings.save()
|
||||
|
||||
|
||||
def _import_media_files(user, extract_dir, overwrite_existing):
|
||||
"""Импорт медиафайлов"""
|
||||
|
||||
imported_count = 0
|
||||
media_dir = extract_dir / 'media'
|
||||
|
||||
if not media_dir.exists():
|
||||
return imported_count
|
||||
|
||||
# Создаем соответствующие директории в медиа
|
||||
user_media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
# Импорт файлов по категориям
|
||||
for category in ['avatars', 'customization', 'link_groups', 'links']:
|
||||
category_dir = media_dir / category
|
||||
if not category_dir.exists():
|
||||
continue
|
||||
|
||||
target_dir = user_media_root / category
|
||||
target_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Копируем файлы
|
||||
for file_path in category_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
target_file = target_dir / file_path.name
|
||||
|
||||
if target_file.exists() and not overwrite_existing:
|
||||
continue
|
||||
|
||||
shutil.copy2(file_path, target_file)
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
@@ -1226,6 +1226,51 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка группы (опционально)</label>
|
||||
{editingGroup?.icon_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущая иконка:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingGroup.icon_url}
|
||||
alt="Текущая иконка группы"
|
||||
className="img-thumbnail"
|
||||
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущую иконку группы?')) {
|
||||
// Удаляем иконку через API
|
||||
fetch(`/api/groups/${editingGroup.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
icon_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальный объект группы
|
||||
setGroups(groups.map(g =>
|
||||
g.id === editingGroup.id
|
||||
? { ...g, icon_url: '' }
|
||||
: g
|
||||
))
|
||||
setEditingGroup({ ...editingGroup, icon_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать иконку группы"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать иконку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
@@ -1236,6 +1281,51 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Фоновое изображение (опционально)</label>
|
||||
{editingGroup?.background_image_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущий фон:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingGroup.background_image_url}
|
||||
alt="Текущий фон группы"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '150px', maxHeight: '80px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущий фон группы?')) {
|
||||
// Удаляем фон через API
|
||||
fetch(`/api/groups/${editingGroup.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
background_image_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальный объект группы
|
||||
setGroups(groups.map(g =>
|
||||
g.id === editingGroup.id
|
||||
? { ...g, background_image_url: '' }
|
||||
: g
|
||||
))
|
||||
setEditingGroup({ ...editingGroup, background_image_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать фон группы"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать фон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
@@ -1323,6 +1413,54 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
{editingLink?.icon_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущая иконка:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingLink.icon_url}
|
||||
alt="Текущая иконка"
|
||||
className="img-thumbnail"
|
||||
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущую иконку ссылки?')) {
|
||||
// Удаляем иконку через API
|
||||
fetch(`/api/links/${editingLink.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
icon_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальные данные
|
||||
setGroups(groups.map(g => ({
|
||||
...g,
|
||||
links: g.links.map(l =>
|
||||
l.id === editingLink.id
|
||||
? { ...l, icon_url: '' }
|
||||
: l
|
||||
)
|
||||
})))
|
||||
setEditingLink({ ...editingLink, icon_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать иконку ссылки"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать иконку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
@@ -1524,6 +1662,12 @@ export default function DashboardClient() {
|
||||
setDesignSettings(newSettings)
|
||||
setShowCustomizationPanel(false)
|
||||
}}
|
||||
user={user}
|
||||
groups={groups}
|
||||
onDataUpdate={() => {
|
||||
// Перезагрузить данные после импорта
|
||||
reloadData()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { TemplatesSelector } from './TemplatesSelector'
|
||||
import { ExportDataModal } from './ExportDataModal'
|
||||
import { ImportDataModal } from './ImportDataModal'
|
||||
import { designTemplates, DesignTemplate } from '../constants/designTemplates'
|
||||
|
||||
interface DesignSettings {
|
||||
@@ -37,13 +39,44 @@ interface DesignSettings {
|
||||
link_overlay_opacity?: number
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon_url?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
icon_url?: string
|
||||
background_image_url?: string
|
||||
is_public?: boolean
|
||||
is_favorite?: boolean
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface CustomizationPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSettingsUpdate: (settings: DesignSettings) => void
|
||||
user?: UserProfile | null
|
||||
groups?: Group[]
|
||||
onDataUpdate?: () => void
|
||||
}
|
||||
|
||||
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: CustomizationPanelProps) {
|
||||
export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate, user, groups = [], onDataUpdate }: CustomizationPanelProps) {
|
||||
const [settings, setSettings] = useState<DesignSettings>({
|
||||
theme_color: '#ffffff',
|
||||
dashboard_layout: 'list',
|
||||
@@ -58,8 +91,12 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
header_text_color: '#000000'
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced'>('templates')
|
||||
const [activeTab, setActiveTab] = useState<'layout' | 'colors' | 'groups' | 'templates' | 'advanced' | 'data'>('templates')
|
||||
const [backgroundImageFile, setBackgroundImageFile] = useState<File | null>(null)
|
||||
|
||||
// Состояния для модалов экспорта/импорта
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -298,6 +335,15 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
Дополнительно
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'data' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('data')}
|
||||
>
|
||||
<i className="bi bi-database me-1"></i>
|
||||
Данные
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Содержимое вкладок */}
|
||||
@@ -464,13 +510,23 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
{settings.background_image_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущее изображение:</label>
|
||||
<div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={settings.background_image_url}
|
||||
alt="Текущий фон"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '200px', maxHeight: '100px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
handleChange('background_image_url', '')
|
||||
}}
|
||||
title="Убрать фон"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать фон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1008,6 +1064,108 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Вкладка: Данные */}
|
||||
{activeTab === 'data' && (
|
||||
<div className="tab-pane fade show active">
|
||||
<div className="row">
|
||||
<div className="col-12 mb-4">
|
||||
<h6 className="text-muted">
|
||||
<i className="bi bi-database me-2"></i>
|
||||
Экспорт и импорт данных профиля
|
||||
</h6>
|
||||
<p className="text-muted small">
|
||||
Создавайте резервные копии данных профиля или восстанавливайте их из архива
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Экспорт данных */}
|
||||
<div className="col-12 mb-4">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="card-title mb-0">
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
Экспорт данных
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-muted small mb-3">
|
||||
Создать архив с данными профиля для резервного копирования или переноса
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
>
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Создать экспорт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Импорт данных */}
|
||||
<div className="col-12 mb-4">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="card-title mb-0">
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
Импорт данных
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-muted small mb-3">
|
||||
Загрузить и восстановить данные из архива экспорта
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Выберите файл архива (.zip)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
// TODO: Обработать загрузку файла и показать превью
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
console.log('Файл выбран:', file.name)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-success"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
Открыть мастер импорта
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* История операций */}
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="card-title mb-0">
|
||||
<i className="bi bi-clock-history me-2"></i>
|
||||
История операций
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-muted">
|
||||
Здесь будет отображаться история экспортов и импортов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1019,6 +1177,49 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-warning"
|
||||
onClick={() => {
|
||||
if (confirm('Вы уверены, что хотите сбросить все настройки интерфейса к значениям по умолчанию? Это действие нельзя отменить.')) {
|
||||
// Сброс к дефолтным настройкам
|
||||
const defaultSettings = {
|
||||
theme_color: '#007bff',
|
||||
background_image_url: '',
|
||||
dashboard_layout: 'list' as const,
|
||||
groups_default_expanded: true,
|
||||
show_group_icons: true,
|
||||
show_link_icons: true,
|
||||
dashboard_background_color: '#ffffff',
|
||||
font_family: 'Inter, sans-serif',
|
||||
custom_css: '',
|
||||
group_text_color: '',
|
||||
link_text_color: '',
|
||||
header_text_color: '',
|
||||
cover_overlay_enabled: false,
|
||||
cover_overlay_color: '#000000',
|
||||
cover_overlay_opacity: 0.3,
|
||||
group_overlay_enabled: false,
|
||||
group_overlay_color: '#000000',
|
||||
group_overlay_opacity: 0.3,
|
||||
show_groups_title: true,
|
||||
group_description_text_color: '',
|
||||
body_font_family: 'Inter, sans-serif',
|
||||
heading_font_family: 'Inter, sans-serif',
|
||||
link_overlay_enabled: false,
|
||||
link_overlay_color: '#000000',
|
||||
link_overlay_opacity: 0.3
|
||||
}
|
||||
setSettings(defaultSettings)
|
||||
onSettingsUpdate(defaultSettings)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
title="Сбросить все настройки к значениям по умолчанию"
|
||||
>
|
||||
<i className="bi bi-arrow-counterclockwise me-2"></i>
|
||||
Сбросить настройки
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
@@ -1040,6 +1241,24 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалы экспорта и импорта */}
|
||||
<ExportDataModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
user={user || null}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
<ImportDataModal
|
||||
isOpen={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImportComplete={() => {
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon_url?: string
|
||||
group: number
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
icon_url?: string
|
||||
background_image_url?: string
|
||||
is_public?: boolean
|
||||
is_favorite?: boolean
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
interface ExportDataModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: UserProfile | null
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
interface ExportSelection {
|
||||
profile: boolean
|
||||
groups: { [key: number]: boolean }
|
||||
links: { [key: number]: boolean }
|
||||
styles: boolean
|
||||
media: boolean
|
||||
}
|
||||
|
||||
export function ExportDataModal({ isOpen, onClose, user, groups }: ExportDataModalProps) {
|
||||
const [selection, setSelection] = useState<ExportSelection>({
|
||||
profile: true,
|
||||
groups: {},
|
||||
links: {},
|
||||
styles: true,
|
||||
media: true
|
||||
})
|
||||
const [expandedGroups, setExpandedGroups] = useState<{ [key: number]: boolean }>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Инициализация выбора при открытии модала
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const newSelection: ExportSelection = {
|
||||
profile: true,
|
||||
groups: {},
|
||||
links: {},
|
||||
styles: true,
|
||||
media: true
|
||||
}
|
||||
|
||||
// По умолчанию выбираем все группы
|
||||
groups.forEach(group => {
|
||||
newSelection.groups[group.id] = true
|
||||
|
||||
// По умолчанию выбираем все ссылки в группе
|
||||
group.links.forEach(link => {
|
||||
newSelection.links[link.id] = true
|
||||
})
|
||||
})
|
||||
|
||||
setSelection(newSelection)
|
||||
}
|
||||
}, [isOpen, groups])
|
||||
|
||||
const handleGroupToggle = (groupId: number) => {
|
||||
const group = groups.find(g => g.id === groupId)
|
||||
if (!group) return
|
||||
|
||||
const newGroupState = !selection.groups[groupId]
|
||||
|
||||
setSelection(prev => {
|
||||
const newSelection = { ...prev }
|
||||
newSelection.groups[groupId] = newGroupState
|
||||
|
||||
// Переключаем все ссылки в группе
|
||||
group.links.forEach(link => {
|
||||
newSelection.links[link.id] = newGroupState
|
||||
})
|
||||
|
||||
return newSelection
|
||||
})
|
||||
}
|
||||
|
||||
const handleLinkToggle = (linkId: number) => {
|
||||
setSelection(prev => ({
|
||||
...prev,
|
||||
links: {
|
||||
...prev.links,
|
||||
[linkId]: !prev.links[linkId]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleGroupExpansion = (groupId: number) => {
|
||||
setExpandedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const selectedGroupIds = Object.keys(selection.groups)
|
||||
.filter(id => selection.groups[parseInt(id)])
|
||||
.map(id => parseInt(id))
|
||||
|
||||
const selectedLinkIds = Object.keys(selection.links)
|
||||
.filter(id => selection.links[parseInt(id)])
|
||||
.map(id => parseInt(id))
|
||||
|
||||
const exportData = {
|
||||
include_profile: selection.profile,
|
||||
include_groups: selectedGroupIds.length > 0,
|
||||
include_links: selectedLinkIds.length > 0,
|
||||
include_styles: selection.styles,
|
||||
include_media: selection.media,
|
||||
selected_groups: selectedGroupIds,
|
||||
selected_links: selectedLinkIds
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
|
||||
|
||||
const response = await fetch(`${API}/api/export/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(exportData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.download_url) {
|
||||
// Скачиваем файл
|
||||
const downloadResponse = await fetch(`${API}${result.download_url}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const blob = await downloadResponse.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `profile_export_${user?.username || 'user'}_${new Date().toISOString().split('T')[0]}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
alert('Экспорт создан и загружен успешно!')
|
||||
onClose()
|
||||
} else {
|
||||
throw new Error('Ошибка при скачивании файла')
|
||||
}
|
||||
} else {
|
||||
throw new Error('Файл экспорта не создан')
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Ошибка при создании экспорта')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка экспорта:', error)
|
||||
alert('Ошибка при создании экспорта: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedCount = () => {
|
||||
const groupsCount = Object.values(selection.groups).filter(Boolean).length
|
||||
const linksCount = Object.values(selection.links).filter(Boolean).length
|
||||
return { groups: groupsCount, links: linksCount }
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const { groups: selectedGroupsCount, links: selectedLinksCount } = getSelectedCount()
|
||||
|
||||
return (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Экспорт данных профиля
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="text-muted mb-4">
|
||||
Выберите данные для включения в архив экспорта
|
||||
</p>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="mb-4">
|
||||
<h6 className="mb-3">Общие данные</h6>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="export-profile"
|
||||
checked={selection.profile}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, profile: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="export-profile">
|
||||
<i className="bi bi-person me-2"></i>
|
||||
Данные профиля (имя, био, аватар)
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="export-styles"
|
||||
checked={selection.styles}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, styles: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="export-styles">
|
||||
<i className="bi bi-palette me-2"></i>
|
||||
Настройки дизайна и стили
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="export-media"
|
||||
checked={selection.media}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, media: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="export-media">
|
||||
<i className="bi bi-image me-2"></i>
|
||||
Медиафайлы (изображения, иконки)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор групп и ссылок */}
|
||||
<div className="mb-4">
|
||||
<h6 className="mb-3">
|
||||
Группы и ссылки
|
||||
<span className="badge bg-secondary ms-2">
|
||||
{selectedGroupsCount} групп, {selectedLinksCount} ссылок
|
||||
</span>
|
||||
</h6>
|
||||
|
||||
<div className="border rounded p-3" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className="mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="form-check me-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id={`group-${group.id}`}
|
||||
checked={selection.groups[group.id] || false}
|
||||
onChange={() => handleGroupToggle(group.id)}
|
||||
/>
|
||||
<label className="form-check-label fw-medium" htmlFor={`group-${group.id}`}>
|
||||
{group.icon_url && (
|
||||
<img
|
||||
src={group.icon_url}
|
||||
alt=""
|
||||
className="me-2"
|
||||
style={{ width: '16px', height: '16px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
{group.name}
|
||||
<span className="text-muted ms-2">({group.links.length} ссылок)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{group.links.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary ms-auto"
|
||||
onClick={() => toggleGroupExpansion(group.id)}
|
||||
>
|
||||
<i className={`bi ${expandedGroups[group.id] ? 'bi-chevron-up' : 'bi-chevron-down'}`}></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список ссылок в группе */}
|
||||
{expandedGroups[group.id] && group.links.length > 0 && (
|
||||
<div className="ms-4 mt-2">
|
||||
{group.links.map(link => (
|
||||
<div key={link.id} className="form-check mb-1">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id={`link-${link.id}`}
|
||||
checked={selection.links[link.id] || false}
|
||||
onChange={() => handleLinkToggle(link.id)}
|
||||
/>
|
||||
<label className="form-check-label small" htmlFor={`link-${link.id}`}>
|
||||
{link.icon_url && (
|
||||
<img
|
||||
src={link.icon_url}
|
||||
alt=""
|
||||
className="me-2"
|
||||
style={{ width: '14px', height: '14px', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
{link.title}
|
||||
<span className="text-muted ms-2">({link.url})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groups.length === 0 && (
|
||||
<p className="text-muted text-center mb-0">
|
||||
Нет групп для экспорта
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Создание экспорта...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Создать и скачать
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface ImportDataModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onImportComplete?: () => void
|
||||
}
|
||||
|
||||
interface ImportPreview {
|
||||
export_info?: {
|
||||
username: string
|
||||
export_date: string
|
||||
}
|
||||
user_data?: {
|
||||
username: string
|
||||
email: string
|
||||
full_name: string
|
||||
bio?: string
|
||||
}
|
||||
groups_count: number
|
||||
links_count: number
|
||||
has_design_settings: boolean
|
||||
media_files: {
|
||||
avatars: number
|
||||
customization: number
|
||||
link_groups: number
|
||||
links: number
|
||||
}
|
||||
groups_preview: Array<{
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
}>
|
||||
links_preview: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
group_id: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface ImportSelection {
|
||||
groups: boolean
|
||||
links: boolean
|
||||
styles: boolean
|
||||
media: boolean
|
||||
overwrite_existing: boolean
|
||||
}
|
||||
|
||||
export function ImportDataModal({ isOpen, onClose, onImportComplete }: ImportDataModalProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [selection, setSelection] = useState<ImportSelection>({
|
||||
groups: true,
|
||||
links: true,
|
||||
styles: true,
|
||||
media: true,
|
||||
overwrite_existing: false
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
setSelectedFile(file)
|
||||
setPreview(null)
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
alert('Пожалуйста, выберите ZIP архив')
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewLoading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('import_file', file)
|
||||
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
|
||||
|
||||
const response = await fetch(`${API}/api/import/preview/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const previewData = await response.json()
|
||||
setPreview(previewData)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Ошибка при анализе архива')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при анализе файла:', error)
|
||||
alert('Ошибка при анализе архива: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
|
||||
setSelectedFile(null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) {
|
||||
alert('Пожалуйста, выберите файл для импорта')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('import_file', selectedFile)
|
||||
formData.append('import_groups', selection.groups.toString())
|
||||
formData.append('import_links', selection.links.toString())
|
||||
formData.append('import_styles', selection.styles.toString())
|
||||
formData.append('import_media', selection.media.toString())
|
||||
formData.append('overwrite_existing', selection.overwrite_existing.toString())
|
||||
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'
|
||||
|
||||
const response = await fetch(`${API}/api/import/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
alert(`Импорт завершен успешно!\nИмпортировано групп: ${result.imported_groups_count}\nИмпортировано ссылок: ${result.imported_links_count}\nИмпортировано медиафайлов: ${result.imported_media_count}`)
|
||||
|
||||
// Очищаем состояние
|
||||
setSelectedFile(null)
|
||||
setPreview(null)
|
||||
|
||||
// Вызываем коллбэк для обновления данных
|
||||
if (onImportComplete) {
|
||||
onImportComplete()
|
||||
}
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Ошибка при импорте')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка импорта:', error)
|
||||
alert('Ошибка при импорте: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
Импорт данных профиля
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Выбор файла */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Выберите архив для импорта</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
}}
|
||||
disabled={previewLoading || loading}
|
||||
/>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="mt-2 small text-muted">
|
||||
<i className="bi bi-file-zip me-1"></i>
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Индикатор загрузки превью */}
|
||||
{previewLoading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Анализ архива...</span>
|
||||
</div>
|
||||
<p className="text-muted mt-2">Анализ архива...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Превью содержимого */}
|
||||
{preview && (
|
||||
<div className="mb-4">
|
||||
<h6 className="mb-3">Содержимое архива</h6>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<h6 className="card-subtitle mb-2 text-muted">Информация об экспорте</h6>
|
||||
{preview.export_info && (
|
||||
<ul className="list-unstyled small">
|
||||
<li><strong>Источник:</strong> {preview.export_info.username}</li>
|
||||
<li><strong>Дата экспорта:</strong> {new Date(preview.export_info.export_date).toLocaleString()}</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<h6 className="card-subtitle mb-2 text-muted">Статистика данных</h6>
|
||||
<ul className="list-unstyled small">
|
||||
<li><i className="bi bi-collection me-1"></i> Групп: {preview.groups_count}</li>
|
||||
<li><i className="bi bi-link-45deg me-1"></i> Ссылок: {preview.links_count}</li>
|
||||
<li><i className="bi bi-palette me-1"></i> Настройки дизайна: {preview.has_design_settings ? 'Есть' : 'Нет'}</li>
|
||||
<li>
|
||||
<i className="bi bi-image me-1"></i>
|
||||
Медиафайлов: {Object.values(preview.media_files).reduce((a, b) => a + b, 0)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Превью групп */}
|
||||
{preview.groups_preview.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<h6 className="card-subtitle mb-2 text-muted">Группы (первые 5)</h6>
|
||||
<div className="list-group list-group-flush small">
|
||||
{preview.groups_preview.map((group, index) => (
|
||||
<div key={index} className="list-group-item p-2">
|
||||
<strong>{group.title}</strong>
|
||||
{group.description && (
|
||||
<div className="text-muted">{group.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Превью ссылок */}
|
||||
{preview.links_preview.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<h6 className="card-subtitle mb-2 text-muted">Ссылки (первые 10)</h6>
|
||||
<div className="list-group list-group-flush small">
|
||||
{preview.links_preview.map((link, index) => (
|
||||
<div key={index} className="list-group-item p-2">
|
||||
<strong>{link.title}</strong>
|
||||
<div className="text-muted">{link.url}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Настройки импорта */}
|
||||
{preview && (
|
||||
<div className="mb-4">
|
||||
<h6 className="mb-3">Настройки импорта</h6>
|
||||
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="import-groups"
|
||||
checked={selection.groups}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, groups: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="import-groups">
|
||||
<i className="bi bi-collection me-2"></i>
|
||||
Импортировать группы ({preview.groups_count})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="import-links"
|
||||
checked={selection.links}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, links: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="import-links">
|
||||
<i className="bi bi-link-45deg me-2"></i>
|
||||
Импортировать ссылки ({preview.links_count})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="import-styles"
|
||||
checked={selection.styles}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, styles: e.target.checked }))}
|
||||
disabled={!preview.has_design_settings}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="import-styles">
|
||||
<i className="bi bi-palette me-2"></i>
|
||||
Импортировать настройки дизайна
|
||||
{!preview.has_design_settings && <span className="text-muted"> (недоступно)</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="import-media"
|
||||
checked={selection.media}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, media: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="import-media">
|
||||
<i className="bi bi-image me-2"></i>
|
||||
Импортировать медиафайлы ({Object.values(preview.media_files).reduce((a, b) => a + b, 0)})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="overwrite-existing"
|
||||
checked={selection.overwrite_existing}
|
||||
onChange={(e) => setSelection(prev => ({ ...prev, overwrite_existing: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="overwrite-existing">
|
||||
<i className="bi bi-exclamation-triangle me-2 text-warning"></i>
|
||||
Перезаписать существующие данные
|
||||
</label>
|
||||
<div className="form-text">
|
||||
Если отключено, существующие группы и ссылки с такими же названиями будут пропущены
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading || previewLoading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
onClick={handleImport}
|
||||
disabled={loading || previewLoading || !preview}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Импорт...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-upload me-2"></i>
|
||||
Импортировать
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
profile_export.zip
Normal file
BIN
profile_export.zip
Normal file
Binary file not shown.
BIN
profile_export_full.zip
Normal file
BIN
profile_export_full.zip
Normal file
Binary file not shown.
BIN
profile_export_with_data.zip
Normal file
BIN
profile_export_with_data.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user