Files
links/frontend/linktree-frontend/src/app/components/ImportDataModal.tsx
Andrey K. Choi 341911a8d3
Some checks failed
continuous-integration/drone/push Build is failing
Полный UI для экспорта/импорта данных профиля
 Новые возможности:
- Добавлена вкладка 'Данные' в панель настроек
- Интерактивный модал экспорта с деревом выбора элементов
- Модал импорта с превью архива и селективным восстановлением
- Автоматическая обработка ZIP архивов и медиафайлов

🎯 Функционал экспорта:
- Древовидный выбор: профиль, группы, конкретные ссылки
- Чекбоксы для типов данных: стили, медиа
- Прогресс-индикаторы и автозагрузка файлов
- Подсчет выбранных элементов в реальном времени

📥 Функционал импорта:
- Drag&Drop загрузка ZIP архивов
- Детальное превью содержимого файла
- Селективный выбор данных для восстановления
- Защита от перезаписи с опциональным режимом

🔗 Интеграция:
- Полная интеграция с существующими API endpoints
- Автообновление данных после импорта
- Обработка ошибок и пользовательские уведомления
- Responsive дизайн для всех устройств
2025-11-09 14:45:09 +09:00

403 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}